mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 02:45:19 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aaa8cc28f | ||
|
|
507cb19b91 | ||
|
|
6cc3a8cde7 |
@@ -25,18 +25,47 @@ jobs:
|
|||||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Configure git
|
||||||
id: git-check
|
|
||||||
run: |
|
run: |
|
||||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
git config user.email "action@github.com"
|
||||||
|
git config user.name "GitHub Action"
|
||||||
- name: Commit and push changes
|
|
||||||
if: steps.git-check.outputs.changes == 'true'
|
- name: Create update branch if changes
|
||||||
|
id: create_branch
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@github.com"
|
# exit early if no changes
|
||||||
git config --local user.name "GitHub Action"
|
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# branch name with timestamp
|
||||||
|
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
|
||||||
git add relays/online_relays_gps.csv
|
git add relays/online_relays_gps.csv
|
||||||
git commit -m "Automated update of relay data - $(date -u)"
|
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||||
git push
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
env:
|
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
- name: Push branch
|
||||||
|
if: steps.create_branch.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
if: steps.create_branch.outputs.changed == 'true'
|
||||||
|
uses: peter-evans/create-pull-request@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: Automated update of relay data
|
||||||
|
branch: ${{ steps.create_branch.outputs.branch }}
|
||||||
|
base: main
|
||||||
|
title: Automated update of relay data
|
||||||
|
body: |
|
||||||
|
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
||||||
|
labels: automated, georelays
|
||||||
|
|
||||||
|
- name: No changes
|
||||||
|
if: steps.create_branch.outputs.changed != 'true'
|
||||||
|
run: echo "No changes to relays/online_relays_gps.csv"
|
||||||
@@ -5,25 +5,14 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Swift Tests (${{ matrix.name }})
|
name: Run Swift Tests
|
||||||
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
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -31,14 +20,8 @@ jobs:
|
|||||||
- name: Set up Swift
|
- name: Set up Swift
|
||||||
uses: swift-actions/setup-swift@v2
|
uses: swift-actions/setup-swift@v2
|
||||||
|
|
||||||
- name: Cache build artifacts
|
- name: Build the package
|
||||||
uses: actions/cache@v4
|
run: swift build
|
||||||
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 --quiet --package-path ${{ matrix.path }}
|
run: swift test --parallel
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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.1
|
MARKETING_VERSION = 1.5.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
@@ -9,4 +9,3 @@ 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
|
|
||||||
|
|||||||
+5
-12
@@ -16,9 +16,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Tor"),
|
||||||
.package(path: "localPackages/Noise"),
|
|
||||||
.package(path: "localPackages/BitFoundation"),
|
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
@@ -27,16 +25,13 @@ 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: "Tor", package: "Tor")
|
||||||
.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",
|
||||||
@@ -48,17 +43,15 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: [
|
dependencies: ["bitchat"],
|
||||||
"bitchat",
|
|
||||||
.product(name: "BitFoundation", package: "BitFoundation")
|
|
||||||
],
|
|
||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization")
|
.process("Localization"),
|
||||||
|
.process("Noise")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
|
|
||||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
Generated
+11
-51
@@ -10,14 +10,11 @@
|
|||||||
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 */; };
|
|
||||||
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 */; };
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -158,20 +155,17 @@
|
|||||||
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 */,
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
|
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
@@ -231,8 +225,6 @@
|
|||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
|
||||||
A63163B72F80CB2D00B8B128 /* Noise */,
|
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -313,8 +305,6 @@
|
|||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
|
||||||
A63163B52F80CB2500B8B128 /* Noise */,
|
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -355,9 +345,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
|
||||||
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -562,7 +550,6 @@
|
|||||||
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;
|
||||||
@@ -573,7 +560,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -623,7 +610,6 @@
|
|||||||
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;
|
||||||
@@ -634,7 +620,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -670,7 +656,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
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;
|
||||||
@@ -762,7 +748,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
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;
|
||||||
@@ -923,21 +909,13 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
|
|
||||||
isa = XCLocalSwiftPackageReference;
|
|
||||||
relativePath = localPackages/Noise;
|
|
||||||
};
|
|
||||||
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;
|
||||||
};
|
};
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/Arti;
|
relativePath = localPackages/Tor;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
@@ -946,8 +924,8 @@
|
|||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = exactVersion;
|
kind = upToNextMajorVersion;
|
||||||
version = 0.21.1;
|
minimumVersion = 0.21.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
@@ -958,24 +936,6 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
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;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import Tor
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -64,10 +63,6 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
NetworkActivationService.shared.start()
|
NetworkActivationService.shared.start()
|
||||||
|
|
||||||
// Start presence service (will wait for Tor readiness)
|
|
||||||
GeohashPresenceService.shared.start()
|
|
||||||
|
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -280,3 +275,8 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var nilIfEmpty: String? {
|
||||||
|
self.isEmpty ? nil : self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ 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,7 +2,8 @@ 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.
|
||||||
actor VoiceRecorder {
|
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||||
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
case recorderInitializationFailed
|
||||||
@@ -11,16 +12,21 @@ actor VoiceRecorder {
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
nonisolated
|
@discardableResult
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
@@ -41,88 +47,106 @@ actor VoiceRecorder {
|
|||||||
|
|
||||||
// MARK: - Recording Lifecycle
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func startRecording() throws -> URL {
|
func startRecording() throws -> URL {
|
||||||
if recorder?.isRecording == true {
|
try queue.sync {
|
||||||
throw RecorderError.recordingInProgress
|
if recorder?.isRecording == true {
|
||||||
|
throw RecorderError.recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
guard session.recordPermission == .granted else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
// allowBluetoothHFP is not available on iOS Simulator
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 16_000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderBitRateKey: 16_000
|
||||||
|
]
|
||||||
|
|
||||||
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
|
audioRecorder.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() async -> URL? {
|
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||||
guard let recorder, recorder.isRecording else {
|
queue.async { [weak self] in
|
||||||
return currentURL
|
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||||
|
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() {
|
||||||
if let recorder, recorder.isRecording {
|
queue.async { [weak self] in
|
||||||
recorder.stop()
|
guard let self = self else { return }
|
||||||
|
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 {
|
|
||||||
try? FileManager.default.removeItem(at: currentURL)
|
// MARK: - Metering
|
||||||
|
|
||||||
|
func currentAveragePower() -> Float {
|
||||||
|
queue.sync {
|
||||||
|
recorder?.updateMeters()
|
||||||
|
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||||
}
|
}
|
||||||
recorder = nil
|
|
||||||
currentURL = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -81,7 +81,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
// MARK: - Three-Layer Identity Model
|
// MARK: - Three-Layer Identity Model
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -332,15 +331,16 @@ 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 previousClaimedNickname,
|
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||||
previousClaimedNickname != identity.claimedNickname {
|
// Remove old nickname from index if changed
|
||||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||||
|
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,16 +532,4 @@ 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] }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -2,8 +2,6 @@
|
|||||||
<!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>
|
||||||
@@ -39,12 +37,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>NSLocationWhenInUseUsageDescription</key>
|
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
<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>
|
||||||
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
//
|
|
||||||
// BitchatMessage+Media.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension BitchatMessage {
|
|
||||||
enum Media {
|
|
||||||
case voice(URL)
|
|
||||||
case image(URL)
|
|
||||||
|
|
||||||
var url: URL {
|
|
||||||
switch self {
|
|
||||||
case .voice(let url), .image(let url):
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
|
||||||
private struct Cache {
|
|
||||||
let filesDir: URL?
|
|
||||||
|
|
||||||
static let shared = Cache()
|
|
||||||
private init() {
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
self.filesDir = filesDir
|
|
||||||
} catch {
|
|
||||||
filesDir = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaAttachment(for nickname: String) -> Media? {
|
|
||||||
guard let baseDirectory = Cache.shared.filesDir else { return nil }
|
|
||||||
|
|
||||||
func url(for category: MimeType.Category) -> URL? {
|
|
||||||
guard content.hasPrefix(category.messagePrefix),
|
|
||||||
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check outgoing first for sent messages, incoming for received
|
|
||||||
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
|
|
||||||
|
|
||||||
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
|
||||||
// Files are checked during playback/display, so missing files fail gracefully
|
|
||||||
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
|
|
||||||
return directory.appendingPathComponent(filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let url = url(for: .audio) {
|
|
||||||
return .voice(url)
|
|
||||||
}
|
|
||||||
if let url = url(for: .image) {
|
|
||||||
return .image(url)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+21
-27
@@ -6,39 +6,33 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import class Foundation.DateFormatter
|
import Foundation
|
||||||
|
|
||||||
import struct Foundation.AttributedString
|
|
||||||
import struct Foundation.Data
|
|
||||||
import struct Foundation.Date
|
|
||||||
import struct Foundation.TimeInterval
|
|
||||||
import struct Foundation.UUID
|
|
||||||
|
|
||||||
/// 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,
|
||||||
/// with support for mentions, replies, and delivery tracking.
|
/// with support for mentions, replies, and delivery tracking.
|
||||||
/// - Note: This is the primary data model for chat messages
|
/// - Note: This is the primary data model for chat messages
|
||||||
public final class BitchatMessage: Codable {
|
final class BitchatMessage: Codable {
|
||||||
public let id: String
|
let id: String
|
||||||
public let sender: String
|
let sender: String
|
||||||
public let content: String
|
let content: String
|
||||||
public let timestamp: Date
|
let timestamp: Date
|
||||||
public let isRelay: Bool
|
let isRelay: Bool
|
||||||
public let originalSender: String?
|
let originalSender: String?
|
||||||
public let isPrivate: Bool
|
let isPrivate: Bool
|
||||||
public let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
public let senderPeerID: PeerID?
|
let senderPeerID: PeerID?
|
||||||
public let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
public var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
// Cached formatted text (not included in Codable)
|
// Cached formatted text (not included in Codable)
|
||||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||||
|
|
||||||
public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||||
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +42,7 @@ public final class BitchatMessage: Codable {
|
|||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
init(
|
||||||
id: String? = nil,
|
id: String? = nil,
|
||||||
sender: String,
|
sender: String,
|
||||||
content: String,
|
content: String,
|
||||||
@@ -78,7 +72,7 @@ public final class BitchatMessage: Codable {
|
|||||||
// MARK: - Equatable Conformance
|
// MARK: - Equatable Conformance
|
||||||
|
|
||||||
extension BitchatMessage: Equatable {
|
extension BitchatMessage: Equatable {
|
||||||
public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||||
return lhs.id == rhs.id &&
|
return lhs.id == rhs.id &&
|
||||||
lhs.sender == rhs.sender &&
|
lhs.sender == rhs.sender &&
|
||||||
lhs.content == rhs.content &&
|
lhs.content == rhs.content &&
|
||||||
@@ -336,15 +330,15 @@ extension BitchatMessage {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public var formattedTimestamp: String {
|
var formattedTimestamp: String {
|
||||||
Self.timestampFormatter.string(from: timestamp)
|
Self.timestampFormatter.string(from: timestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
public func cleanedAndDeduped() -> [Element] {
|
func cleanedAndDeduped() -> [Element] {
|
||||||
let arr = filter { $0.content.trimmed.isEmpty == false }
|
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||||
guard arr.count > 1 else {
|
guard arr.count > 1 else {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
+17
-22
@@ -6,26 +6,24 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.Date
|
|
||||||
|
|
||||||
/// 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,
|
||||||
/// including TTL for hop limiting and optional encryption.
|
/// including TTL for hop limiting and optional encryption.
|
||||||
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
||||||
public struct BitchatPacket: Codable {
|
struct BitchatPacket: Codable {
|
||||||
let version: UInt8
|
let version: UInt8
|
||||||
public let type: UInt8
|
let type: UInt8
|
||||||
public let senderID: Data
|
let senderID: Data
|
||||||
public let recipientID: Data?
|
let recipientID: Data?
|
||||||
public let timestamp: UInt64
|
let timestamp: UInt64
|
||||||
public let payload: Data
|
let payload: Data
|
||||||
public var signature: Data?
|
var signature: Data?
|
||||||
public var ttl: UInt8
|
var ttl: UInt8
|
||||||
public var route: [Data]?
|
var route: [Data]?
|
||||||
public var isRSR: Bool
|
|
||||||
|
|
||||||
public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
@@ -35,11 +33,10 @@ public struct BitchatPacket: Codable {
|
|||||||
self.signature = signature
|
self.signature = signature
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = route
|
self.route = route
|
||||||
self.isRSR = isRSR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// Convenience initializer for new binary format
|
||||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
|
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||||
self.version = 1
|
self.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
@@ -59,25 +56,24 @@ public struct BitchatPacket: Codable {
|
|||||||
self.signature = nil
|
self.signature = nil
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = nil
|
self.route = nil
|
||||||
self.isRSR = isRSR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var data: Data? {
|
var data: Data? {
|
||||||
BinaryProtocol.encode(self)
|
BinaryProtocol.encode(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func toBinaryData(padding: Bool = true) -> Data? {
|
func toBinaryData(padding: Bool = true) -> Data? {
|
||||||
BinaryProtocol.encode(self, padding: padding)
|
BinaryProtocol.encode(self, padding: padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward-compatible helper (defaults to padded encoding)
|
// Backward-compatible helper (defaults to padded encoding)
|
||||||
public func toBinaryData() -> Data? {
|
func toBinaryData() -> Data? {
|
||||||
toBinaryData(padding: true)
|
toBinaryData(padding: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create binary representation for signing (without signature and TTL fields)
|
/// Create binary representation for signing (without signature and TTL fields)
|
||||||
/// TTL is excluded because it changes during packet relay operations
|
/// TTL is excluded because it changes during packet relay operations
|
||||||
public func toBinaryDataForSigning() -> Data? {
|
func toBinaryDataForSigning() -> Data? {
|
||||||
// Create a copy without signature and with fixed TTL for signing
|
// Create a copy without signature and with fixed TTL for signing
|
||||||
// TTL must be excluded because it changes during relay
|
// TTL must be excluded because it changes during relay
|
||||||
let unsignedPacket = BitchatPacket(
|
let unsignedPacket = BitchatPacket(
|
||||||
@@ -89,13 +85,12 @@ public struct BitchatPacket: Codable {
|
|||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
version: version,
|
version: version,
|
||||||
route: route,
|
route: route
|
||||||
isRSR: false // RSR flag is mutable and not part of the signature
|
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func from(_ data: Data) -> BitchatPacket? {
|
static func from(_ data: Data) -> BitchatPacket? {
|
||||||
BinaryProtocol.decode(data)
|
BinaryProtocol.decode(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
|
|
||||||
/// Provides privacy-preserving message padding to obscure actual content length.
|
/// Provides privacy-preserving message padding to obscure actual content length.
|
||||||
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
||||||
+45
-52
@@ -1,27 +1,15 @@
|
|||||||
//
|
//
|
||||||
// PeerID.swift
|
// PeerID.swift
|
||||||
// BitFoundation
|
// bitchat
|
||||||
//
|
//
|
||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.CharacterSet
|
|
||||||
|
|
||||||
public struct PeerID: Equatable, Hashable, Sendable {
|
struct PeerID: Equatable, Hashable {
|
||||||
enum Constants {
|
enum Prefix: String, CaseIterable {
|
||||||
/// 16
|
|
||||||
static let nostrConvKeyPrefixLength = 16
|
|
||||||
/// 8
|
|
||||||
static let nostrShortKeyDisplayLength = 8
|
|
||||||
/// 64
|
|
||||||
fileprivate static let maxIDLength = 64
|
|
||||||
/// 16
|
|
||||||
fileprivate static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum Prefix: String, CaseIterable, Sendable {
|
|
||||||
/// When no prefix is provided
|
/// When no prefix is provided
|
||||||
case empty = ""
|
case empty = ""
|
||||||
/// `"mesh:"`
|
/// `"mesh:"`
|
||||||
@@ -35,15 +23,15 @@ public struct PeerID: Equatable, Hashable, Sendable {
|
|||||||
/// `"nostr:"` (+ 8 characters hex)
|
/// `"nostr:"` (+ 8 characters hex)
|
||||||
case geoChat = "nostr:"
|
case geoChat = "nostr:"
|
||||||
}
|
}
|
||||||
|
|
||||||
public let prefix: Prefix
|
let prefix: Prefix
|
||||||
|
|
||||||
/// Returns the actual value without any prefix
|
/// Returns the actual value without any prefix
|
||||||
public let bare: String
|
let bare: String
|
||||||
|
|
||||||
/// Returns the full `id` value by combining `(prefix + bare)`
|
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||||
public var id: String { prefix.rawValue + bare }
|
var id: String { prefix.rawValue + bare }
|
||||||
|
|
||||||
// Private so the callers have to go through a convenience init
|
// Private so the callers have to go through a convenience init
|
||||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
@@ -53,17 +41,17 @@ public struct PeerID: Equatable, Hashable, Sendable {
|
|||||||
|
|
||||||
// MARK: - Convenience Inits
|
// MARK: - Convenience Inits
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||||
init(nostr_ pubKey: String) {
|
init(nostr_ pubKey: String) {
|
||||||
self.init(prefix: .geoDM, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength))
|
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||||
init(nostr pubKey: String) {
|
init(nostr pubKey: String) {
|
||||||
self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength))
|
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||||
init(str: any StringProtocol) {
|
init(str: any StringProtocol) {
|
||||||
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||||
@@ -72,23 +60,23 @@ public extension PeerID {
|
|||||||
self.init(prefix: .empty, bare: str)
|
self.init(prefix: .empty, bare: str)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to handle `Optional<String>`
|
/// Convenience init to handle `Optional<String>`
|
||||||
init?(str: (any StringProtocol)?) {
|
init?(str: (any StringProtocol)?) {
|
||||||
guard let str else { return nil }
|
guard let str else { return nil }
|
||||||
self.init(str: str)
|
self.init(str: str)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to create PeerID by converting Data to String
|
/// Convenience init to create PeerID by converting Data to String
|
||||||
init?(data: Data) {
|
init?(data: Data) {
|
||||||
self.init(str: String(data: data, encoding: .utf8))
|
self.init(str: String(data: data, encoding: .utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to "hide" hex-encoding implementation detail
|
/// Convenience init to "hide" hex-encoding implementation detail
|
||||||
init(hexData: Data) {
|
init(hexData: Data) {
|
||||||
self.init(str: hexData.hexEncodedString())
|
self.init(str: hexData.hexEncodedString())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to "hide" hex-encoding implementation detail
|
/// Convenience init to "hide" hex-encoding implementation detail
|
||||||
init?(hexData: Data?) {
|
init?(hexData: Data?) {
|
||||||
guard let hexData else { return nil }
|
guard let hexData else { return nil }
|
||||||
@@ -98,12 +86,12 @@ public extension PeerID {
|
|||||||
|
|
||||||
// MARK: - Noise Public Key Helpers
|
// MARK: - Noise Public Key Helpers
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||||
init(publicKey: Data) {
|
init(publicKey: Data) {
|
||||||
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||||
func toShort() -> PeerID {
|
func toShort() -> PeerID {
|
||||||
if let noiseKey {
|
if let noiseKey {
|
||||||
@@ -116,11 +104,11 @@ public extension PeerID {
|
|||||||
// MARK: - Codable
|
// MARK: - Codable
|
||||||
|
|
||||||
extension PeerID: Codable {
|
extension PeerID: Codable {
|
||||||
public init(from decoder: any Decoder) throws {
|
init(from decoder: any Decoder) throws {
|
||||||
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: any Encoder) throws {
|
func encode(to encoder: any Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
try container.encode(id)
|
try container.encode(id)
|
||||||
}
|
}
|
||||||
@@ -128,27 +116,27 @@ extension PeerID: Codable {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
var isEmpty: Bool {
|
var isEmpty: Bool {
|
||||||
id.isEmpty
|
id.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if `id` starts with "`nostr:`"
|
/// Returns true if `id` starts with "`nostr:`"
|
||||||
var isGeoChat: Bool {
|
var isGeoChat: Bool {
|
||||||
prefix == .geoChat
|
prefix == .geoChat
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if `id` starts with "`nostr_`"
|
/// Returns true if `id` starts with "`nostr_`"
|
||||||
var isGeoDM: Bool {
|
var isGeoDM: Bool {
|
||||||
prefix == .geoDM
|
prefix == .geoDM
|
||||||
}
|
}
|
||||||
|
|
||||||
func toPercentEncoded() -> String {
|
func toPercentEncoded() -> String {
|
||||||
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
var routingData: Data? {
|
var routingData: Data? {
|
||||||
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
||||||
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
||||||
@@ -164,45 +152,50 @@ public extension PeerID {
|
|||||||
|
|
||||||
// MARK: - Validation
|
// MARK: - Validation
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
|
private enum Constants {
|
||||||
|
static let maxIDLength = 64
|
||||||
|
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||||
var isValid: Bool {
|
var isValid: Bool {
|
||||||
if prefix != .empty {
|
if prefix != .empty {
|
||||||
return PeerID(str: bare).isValid
|
return PeerID(str: bare).isValid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||||
if isShort || isNoiseKeyHex {
|
if isShort || isNoiseKeyHex {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If length equals short or full but isn't valid hex, reject
|
// If length equals short or full but isn't valid hex, reject
|
||||||
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||||
return !id.isEmpty &&
|
return !id.isEmpty &&
|
||||||
id.count < Constants.maxIDLength &&
|
id.count < Constants.maxIDLength &&
|
||||||
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the `bare` id is all hex
|
/// Returns true if the `bare` id is all hex
|
||||||
var isHex: Bool {
|
var isHex: Bool {
|
||||||
bare.allSatisfy { $0.isHexDigit }
|
bare.allSatisfy { $0.isHexDigit }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short routing IDs (exact 16-hex)
|
/// Short routing IDs (exact 16-hex)
|
||||||
var isShort: Bool {
|
var isShort: Bool {
|
||||||
bare.count == Constants.hexIDLength && isHex
|
bare.count == Constants.hexIDLength && isHex
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full Noise key hex (exact 64-hex)
|
/// Full Noise key hex (exact 64-hex)
|
||||||
var isNoiseKeyHex: Bool {
|
var isNoiseKeyHex: Bool {
|
||||||
noiseKey != nil
|
noiseKey != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full Noise key (exact 64-hex) as Data
|
/// Full Noise key (exact 64-hex) as Data
|
||||||
var noiseKey: Data? {
|
var noiseKey: Data? {
|
||||||
guard bare.count == Constants.maxIDLength else { return nil }
|
guard bare.count == Constants.maxIDLength else { return nil }
|
||||||
@@ -213,7 +206,7 @@ public extension PeerID {
|
|||||||
// MARK: - Comparable
|
// MARK: - Comparable
|
||||||
|
|
||||||
extension PeerID: Comparable {
|
extension PeerID: Comparable {
|
||||||
public static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||||
lhs.id < rhs.id
|
lhs.id < rhs.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,7 +215,7 @@ extension PeerID: Comparable {
|
|||||||
|
|
||||||
extension PeerID: CustomStringConvertible {
|
extension PeerID: CustomStringConvertible {
|
||||||
/// So it returns the actual `id` like before even inside another String
|
/// So it returns the actual `id` like before even inside another String
|
||||||
public var description: String {
|
var description: String {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
|
|||||||
@@ -9,16 +9,12 @@ struct RequestSyncPacket {
|
|||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
let types: SyncTypeFlags?
|
let types: SyncTypeFlags?
|
||||||
let sinceTimestamp: UInt64?
|
|
||||||
let fragmentIdFilter: String?
|
|
||||||
|
|
||||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||||
self.p = p
|
self.p = p
|
||||||
self.m = m
|
self.m = m
|
||||||
self.data = data
|
self.data = data
|
||||||
self.types = types
|
self.types = types
|
||||||
self.sinceTimestamp = sinceTimestamp
|
|
||||||
self.fragmentIdFilter = fragmentIdFilter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data {
|
func encode() -> Data {
|
||||||
@@ -40,24 +36,15 @@ struct RequestSyncPacket {
|
|||||||
if let typesData = types?.toData() {
|
if let typesData = types?.toData() {
|
||||||
putTLV(0x04, typesData)
|
putTLV(0x04, typesData)
|
||||||
}
|
}
|
||||||
if let ts = sinceTimestamp {
|
|
||||||
var tsBE = ts.bigEndian
|
|
||||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
|
||||||
}
|
|
||||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
|
||||||
putTLV(0x06, fidData)
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||||
var off = 0
|
var off = 0
|
||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
var types: SyncTypeFlags? = nil
|
var types: SyncTypeFlags? = nil
|
||||||
var sinceTimestamp: UInt64? = nil
|
|
||||||
var fragmentIdFilter: String? = nil
|
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -81,22 +68,12 @@ struct RequestSyncPacket {
|
|||||||
if let decoded = SyncTypeFlags.decode(v) {
|
if let decoded = SyncTypeFlags.decode(v) {
|
||||||
types = decoded
|
types = decoded
|
||||||
}
|
}
|
||||||
case 0x05:
|
|
||||||
if v.count == 8 {
|
|
||||||
var ts: UInt64 = 0
|
|
||||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
|
||||||
sinceTimestamp = ts
|
|
||||||
}
|
|
||||||
case 0x06:
|
|
||||||
if let fid = String(data: v, encoding: .utf8) {
|
|
||||||
fragmentIdFilter = fid
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
break // forward compatible; ignore unknown TLVs
|
break // forward compatible; ignore unknown TLVs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-118
@@ -78,7 +78,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -166,23 +165,19 @@ final class NoiseCipherState {
|
|||||||
// MARK: - Sliding Window Replay Protection
|
// MARK: - Sliding Window Replay Protection
|
||||||
|
|
||||||
/// Check if nonce is valid for replay protection
|
/// Check if nonce is valid for replay protection
|
||||||
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
|
||||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
|
||||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
|
||||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
|
||||||
return false // Too old, outside window
|
return false // Too old, outside window
|
||||||
}
|
}
|
||||||
|
|
||||||
if receivedNonce > highestReceivedNonce {
|
if receivedNonce > highestReceivedNonce {
|
||||||
return true // Always accept newer nonces
|
return true // Always accept newer nonces
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = Int(highestReceivedNonce - receivedNonce)
|
let offset = Int(highestReceivedNonce - receivedNonce)
|
||||||
let byteIndex = offset / 8
|
let byteIndex = offset / 8
|
||||||
let bitIndex = offset % 8
|
let bitIndex = offset % 8
|
||||||
|
|
||||||
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,20 +347,16 @@ final class NoiseCipherState {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||||
|
|
||||||
// BCH-01-010: Atomic nonce state update
|
|
||||||
// Both replay window marking and nonce increment must complete together
|
|
||||||
// to prevent state desynchronization. We perform both after successful
|
|
||||||
// decryption only, ensuring state consistency on any failure path.
|
|
||||||
if useExtractedNonce {
|
if useExtractedNonce {
|
||||||
|
// Mark nonce as seen after successful decryption
|
||||||
markNonceAsSeen(decryptionNonce)
|
markNonceAsSeen(decryptionNonce)
|
||||||
}
|
}
|
||||||
nonce += 1
|
nonce += 1
|
||||||
|
|
||||||
return plaintext
|
return plaintext
|
||||||
} catch {
|
} catch {
|
||||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
|
||||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||||
|
// Log authentication failures with nonce info
|
||||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -385,16 +376,6 @@ final class NoiseCipherState {
|
|||||||
replayWindow[i] = 0
|
replayWindow[i] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
func setNonceForTesting(_ nonce: UInt64) {
|
|
||||||
self.nonce = nonce
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
|
|
||||||
try extractNonceFromCiphertextPayload(combinedPayload)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Symmetric State
|
// MARK: - Symmetric State
|
||||||
@@ -474,36 +455,13 @@ final class NoiseSymmetricState {
|
|||||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||||
let tempKey1 = SymmetricKey(data: output[0])
|
let tempKey1 = SymmetricKey(data: output[0])
|
||||||
let tempKey2 = SymmetricKey(data: output[1])
|
let tempKey2 = SymmetricKey(data: output[1])
|
||||||
|
|
||||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
||||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
||||||
|
|
||||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
|
||||||
// The chaining key and hash should not be retained after handshake completes
|
|
||||||
clearSensitiveData()
|
|
||||||
|
|
||||||
return (c1, c2)
|
return (c1, c2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-010: Securely clear sensitive cryptographic state
|
|
||||||
/// Called after split() to clear chaining key and hash per Noise spec
|
|
||||||
func clearSensitiveData() {
|
|
||||||
// Clear chaining key by overwriting with zeros
|
|
||||||
let chainingKeyCount = chainingKey.count
|
|
||||||
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
|
||||||
|
|
||||||
// Clear hash by overwriting with zeros
|
|
||||||
let hashCount = hash.count
|
|
||||||
hash = Data(repeating: 0, count: hashCount)
|
|
||||||
|
|
||||||
// Clear the internal cipher state
|
|
||||||
cipherState.clearSensitiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
clearSensitiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// HKDF implementation
|
// HKDF implementation
|
||||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||||
@@ -596,9 +554,8 @@ final class NoiseHandshakeState {
|
|||||||
break // No pre-message keys
|
break // No pre-message keys
|
||||||
case .IK, .NK:
|
case .IK, .NK:
|
||||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||||
|
_ = symmetricState.getHandshakeHash()
|
||||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||||
} else if role == .responder, let localStatic = localStaticPublic {
|
|
||||||
symmetricState.mixHash(localStatic.rawRepresentation)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -850,20 +807,16 @@ final class NoiseHandshakeState {
|
|||||||
return currentPattern >= messagePatterns.count
|
return currentPattern >= messagePatterns.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||||
guard isHandshakeComplete() else {
|
guard isHandshakeComplete() else {
|
||||||
throw NoiseError.handshakeNotComplete
|
throw NoiseError.handshakeNotComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
|
||||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
|
||||||
|
|
||||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
||||||
|
|
||||||
// Initiator uses c1 for sending, c2 for receiving
|
// Initiator uses c1 for sending, c2 for receiving
|
||||||
// Responder uses c2 for sending, c1 for receiving
|
// Responder uses c2 for sending, c1 for receiving
|
||||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
@@ -873,20 +826,6 @@ final class NoiseHandshakeState {
|
|||||||
func getHandshakeHash() -> Data {
|
func getHandshakeHash() -> Data {
|
||||||
return symmetricState.getHandshakeHash()
|
return symmetricState.getHandshakeHash()
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
|
|
||||||
try performDHOperation(pattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCurrentPatternForTesting(_ currentPattern: Int) {
|
|
||||||
self.currentPattern = currentPattern
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
|
|
||||||
self.remoteEphemeralPublic = key
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Pattern Extensions
|
// MARK: - Pattern Extensions
|
||||||
@@ -924,7 +863,7 @@ extension NoisePattern {
|
|||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
||||||
public enum NoiseError: Error {
|
enum NoiseError: Error {
|
||||||
case uninitializedCipher
|
case uninitializedCipher
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case handshakeComplete
|
case handshakeComplete
|
||||||
@@ -938,47 +877,22 @@ public enum NoiseError: Error {
|
|||||||
case nonceExceeded
|
case nonceExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constant-Time Operations
|
|
||||||
|
|
||||||
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
|
||||||
/// This function compares two Data objects in constant time, preventing
|
|
||||||
/// information leakage via timing analysis.
|
|
||||||
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
|
||||||
guard a.count == b.count else { return false }
|
|
||||||
|
|
||||||
var result: UInt8 = 0
|
|
||||||
for i in 0..<a.count {
|
|
||||||
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BCH-01-010: Constant-time check if all bytes are zero
|
|
||||||
private func constantTimeIsZero(_ data: Data) -> Bool {
|
|
||||||
var result: UInt8 = 0
|
|
||||||
for byte in data {
|
|
||||||
result |= byte
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Key Validation
|
// MARK: - Key Validation
|
||||||
|
|
||||||
extension NoiseHandshakeState {
|
extension NoiseHandshakeState {
|
||||||
/// Validate a Curve25519 public key
|
/// Validate a Curve25519 public key
|
||||||
/// Checks for weak/invalid keys that could compromise security
|
/// Checks for weak/invalid keys that could compromise security
|
||||||
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
|
||||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||||
// Check key length
|
// Check key length
|
||||||
guard keyData.count == 32 else {
|
guard keyData.count == 32 else {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
// Check for all-zero key (point at infinity)
|
||||||
if constantTimeIsZero(keyData) {
|
if keyData.allSatisfy({ $0 == 0 }) {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for low-order points that could enable small subgroup attacks
|
// Check for low-order points that could enable small subgroup attacks
|
||||||
// These are the known bad points for Curve25519
|
// These are the known bad points for Curve25519
|
||||||
let lowOrderPoints: [Data] = [
|
let lowOrderPoints: [Data] = [
|
||||||
@@ -999,21 +913,13 @@ extension NoiseHandshakeState {
|
|||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||||
]
|
]
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check against known bad points
|
// Check against known bad points
|
||||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
if lowOrderPoints.contains(keyData) {
|
||||||
var foundBadPoint = false
|
|
||||||
for badPoint in lowOrderPoints {
|
|
||||||
if constantTimeCompare(keyData, badPoint) {
|
|
||||||
foundBadPoint = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundBadPoint {
|
|
||||||
SecureLogger.warning("Low-order point detected", category: .security)
|
SecureLogger.warning("Low-order point detected", category: .security)
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to create the key - CryptoKit will validate curve points internally
|
// Try to create the key - CryptoKit will validate curve points internally
|
||||||
do {
|
do {
|
||||||
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
||||||
+5
-8
@@ -7,10 +7,9 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public final class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
@@ -19,10 +18,8 @@ public final class NoiseRateLimiter {
|
|||||||
private var globalMessageTimestamps: [Date] = []
|
private var globalMessageTimestamps: [Date] = []
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
public init() {}
|
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||||
|
|
||||||
public func allowHandshake(from peerID: PeerID) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
@@ -51,7 +48,7 @@ public final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func allowMessage(from peerID: PeerID) -> Bool {
|
func allowMessage(from peerID: PeerID) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
@@ -87,7 +84,7 @@ public final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func resetAll() {
|
func resetAll() {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.handshakeTimestamps.removeAll()
|
self.handshakeTimestamps.removeAll()
|
||||||
self.messageTimestamps.removeAll()
|
self.messageTimestamps.removeAll()
|
||||||
+5
-5
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum NoiseSecurityConstants {
|
enum NoiseSecurityConstants {
|
||||||
// Maximum message size to prevent memory exhaustion
|
// Maximum message size to prevent memory exhaustion
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||||
|
|
||||||
@@ -28,10 +28,10 @@ public enum NoiseSecurityConstants {
|
|||||||
static let maxSessionsPerPeer = 3
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
public static let maxHandshakesPerMinute = 10
|
static let maxHandshakesPerMinute = 10
|
||||||
public static let maxMessagesPerSecond = 100
|
static let maxMessagesPerSecond = 100
|
||||||
|
|
||||||
// Global rate limiting (across all peers)
|
// Global rate limiting (across all peers)
|
||||||
public static let maxGlobalHandshakesPerMinute = 30
|
static let maxGlobalHandshakesPerMinute = 30
|
||||||
public static let maxGlobalMessagesPerSecond = 500
|
static let maxGlobalMessagesPerSecond = 500
|
||||||
}
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum NoiseSecurityError: Error {
|
enum NoiseSecurityError: Error {
|
||||||
case sessionExpired
|
case sessionExpired
|
||||||
case sessionExhausted
|
case sessionExhausted
|
||||||
case messageTooLarge
|
case messageTooLarge
|
||||||
+3
-3
@@ -8,15 +8,15 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct NoiseSecurityValidator {
|
struct NoiseSecurityValidator {
|
||||||
|
|
||||||
/// Validate message size
|
/// Validate message size
|
||||||
public static func validateMessageSize(_ data: Data) -> Bool {
|
static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate handshake message size
|
/// Validate handshake message size
|
||||||
public static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+19
-20
@@ -9,9 +9,8 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
public class NoiseSession {
|
class NoiseSession {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
let role: NoiseRole
|
let role: NoiseRole
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
@@ -103,23 +102,23 @@ public class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
// Get transport ciphers
|
||||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
// Store remote static key
|
// Store remote static key
|
||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = hash
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
// Generate response
|
// Generate response
|
||||||
@@ -129,20 +128,20 @@ public class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete after writing
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
// Get transport ciphers
|
||||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
// Store remote static key
|
// Store remote static key
|
||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = hash
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
@@ -182,7 +181,7 @@ public class NoiseSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func isEstablished() -> Bool {
|
func isEstablished() -> Bool {
|
||||||
return sessionQueue.sync {
|
return sessionQueue.sync {
|
||||||
if case .established = state {
|
if case .established = state {
|
||||||
return true
|
return true
|
||||||
@@ -217,8 +216,8 @@ public class NoiseSession {
|
|||||||
sentHandshakeMessages.removeAll()
|
sentHandshakeMessages.removeAll()
|
||||||
|
|
||||||
// Clear handshake hash
|
// Clear handshake hash
|
||||||
if handshakeHash != nil {
|
if var hash = handshakeHash {
|
||||||
keychain.secureClear(&handshakeHash!)
|
keychain.secureClear(&hash)
|
||||||
}
|
}
|
||||||
handshakeHash = nil
|
handshakeHash = nil
|
||||||
|
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
public enum NoiseSessionError: Error, Equatable {
|
enum NoiseSessionError: Error, Equatable {
|
||||||
case invalidState
|
case invalidState
|
||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
+25
-37
@@ -9,53 +9,31 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
public final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
public var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
public init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
self.localStaticKey = localStaticKey
|
self.localStaticKey = localStaticKey
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.sessionFactory = { peerID, role in
|
|
||||||
SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: role,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
init(
|
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
|
||||||
keychain: KeychainManagerProtocol,
|
|
||||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
|
||||||
) {
|
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
self.sessionFactory = sessionFactory
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Session Management
|
// MARK: - Session Management
|
||||||
|
|
||||||
public func getSession(for peerID: PeerID) -> NoiseSession? {
|
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||||
return managerQueue.sync {
|
return managerQueue.sync {
|
||||||
return sessions[peerID]
|
return sessions[peerID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func removeSession(for peerID: PeerID) {
|
func removeSession(for peerID: PeerID) {
|
||||||
managerQueue.sync(flags: .barrier) {
|
managerQueue.sync(flags: .barrier) {
|
||||||
if let session = sessions.removeValue(forKey: peerID) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
session.reset() // Clear sensitive data before removing
|
||||||
@@ -63,7 +41,7 @@ public final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func removeAllSessions() {
|
func removeAllSessions() {
|
||||||
managerQueue.sync(flags: .barrier) {
|
managerQueue.sync(flags: .barrier) {
|
||||||
for (_, session) in sessions {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
@@ -74,7 +52,7 @@ public final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Handshake Helpers
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
public func initiateHandshake(with peerID: PeerID) throws -> Data {
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
// Check if we already have an established session
|
// Check if we already have an established session
|
||||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||||
@@ -88,7 +66,12 @@ public final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
let session = sessionFactory(peerID, .initiator)
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
sessions[peerID] = session
|
sessions[peerID] = session
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -103,7 +86,7 @@ public final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
var shouldCreateNew = false
|
var shouldCreateNew = false
|
||||||
@@ -134,7 +117,12 @@ public final class NoiseSessionManager {
|
|||||||
// Get or create session
|
// Get or create session
|
||||||
let session: NoiseSession
|
let session: NoiseSession
|
||||||
if shouldCreateNew {
|
if shouldCreateNew {
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .responder,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
} else {
|
||||||
@@ -173,7 +161,7 @@ public final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
public func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
guard let session = getSession(for: peerID) else {
|
guard let session = getSession(for: peerID) else {
|
||||||
throw NoiseSessionError.sessionNotFound
|
throw NoiseSessionError.sessionNotFound
|
||||||
}
|
}
|
||||||
@@ -181,7 +169,7 @@ public final class NoiseSessionManager {
|
|||||||
return try session.encrypt(plaintext)
|
return try session.encrypt(plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||||
guard let session = getSession(for: peerID) else {
|
guard let session = getSession(for: peerID) else {
|
||||||
throw NoiseSessionError.sessionNotFound
|
throw NoiseSessionError.sessionNotFound
|
||||||
}
|
}
|
||||||
@@ -191,13 +179,13 @@ public final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Key Management
|
// MARK: - Key Management
|
||||||
|
|
||||||
public func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Rekeying
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
public func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||||
return managerQueue.sync {
|
return managerQueue.sync {
|
||||||
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||||
|
|
||||||
@@ -213,7 +201,7 @@ public final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func initiateRekey(for peerID: PeerID) throws {
|
func initiateRekey(for peerID: PeerID) throws {
|
||||||
// Remove old session
|
// Remove old session
|
||||||
removeSession(for: peerID)
|
removeSession(for: peerID)
|
||||||
|
|
||||||
+1
-5
@@ -10,7 +10,7 @@ import Foundation
|
|||||||
|
|
||||||
final class SecureNoiseSession: NoiseSession {
|
final class SecureNoiseSession: NoiseSession {
|
||||||
private(set) var messageCount: UInt64 = 0
|
private(set) var messageCount: UInt64 = 0
|
||||||
private var sessionStartTime = Date()
|
private let sessionStartTime = Date()
|
||||||
private(set) var lastActivityTime = Date()
|
private(set) var lastActivityTime = Date()
|
||||||
|
|
||||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
@@ -77,9 +77,5 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
func setMessageCountForTesting(_ count: UInt64) {
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
messageCount = count
|
messageCount = count
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionStartTimeForTesting(_ date: Date) {
|
|
||||||
sessionStartTime = date
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -8,154 +8,39 @@ import AppKit
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
struct GeoRelayDirectoryDependencies {
|
|
||||||
var userDefaults: UserDefaults
|
|
||||||
var notificationCenter: NotificationCenter
|
|
||||||
var now: () -> Date
|
|
||||||
var remoteURL: URL
|
|
||||||
var fetchInterval: TimeInterval
|
|
||||||
var refreshCheckInterval: TimeInterval
|
|
||||||
var retryInitialSeconds: TimeInterval
|
|
||||||
var retryMaxSeconds: TimeInterval
|
|
||||||
var awaitTorReady: @Sendable () async -> Bool
|
|
||||||
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
|
||||||
var readData: (URL) -> Data?
|
|
||||||
var writeData: (Data, URL) throws -> Void
|
|
||||||
var cacheURL: () -> URL?
|
|
||||||
var bundledCSVURLs: () -> [URL]
|
|
||||||
var currentDirectoryPath: () -> String?
|
|
||||||
var retrySleep: (TimeInterval) async -> Void
|
|
||||||
var activeNotificationName: Notification.Name?
|
|
||||||
var autoStart: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension GeoRelayDirectoryDependencies {
|
|
||||||
@MainActor
|
|
||||||
static func live() -> Self {
|
|
||||||
#if os(iOS)
|
|
||||||
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
|
||||||
#elseif os(macOS)
|
|
||||||
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
|
||||||
#else
|
|
||||||
let activeNotificationName: Notification.Name? = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return Self(
|
|
||||||
userDefaults: .standard,
|
|
||||||
notificationCenter: .default,
|
|
||||||
now: Date.init,
|
|
||||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
|
||||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
|
||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
|
||||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
|
||||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
|
||||||
makeFetchData: {
|
|
||||||
let session = TorURLSession.shared.session
|
|
||||||
return { request in
|
|
||||||
let (data, _) = try await session.data(for: request)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
},
|
|
||||||
readData: { try? Data(contentsOf: $0) },
|
|
||||||
writeData: { data, url in
|
|
||||||
try data.write(to: url, options: .atomic)
|
|
||||||
},
|
|
||||||
cacheURL: {
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
return dir.appendingPathComponent("georelays_cache.csv")
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
},
|
|
||||||
bundledCSVURLs: {
|
|
||||||
[
|
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
|
||||||
].compactMap { $0 }
|
|
||||||
},
|
|
||||||
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
|
||||||
retrySleep: { delay in
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
},
|
|
||||||
activeNotificationName: activeNotificationName,
|
|
||||||
autoStart: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GeoRelayDirectory {
|
final class GeoRelayDirectory {
|
||||||
private final class CleanupState {
|
struct Entry: Hashable {
|
||||||
let notificationCenter: NotificationCenter
|
|
||||||
var observers: [NSObjectProtocol] = []
|
|
||||||
var refreshTimer: Timer?
|
|
||||||
var retryTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(notificationCenter: NotificationCenter) {
|
|
||||||
self.notificationCenter = notificationCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
observers.forEach { notificationCenter.removeObserver($0) }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
retryTask?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Entry: Hashable, Sendable {
|
|
||||||
let host: String
|
let host: String
|
||||||
let lat: Double
|
let lat: Double
|
||||||
let lon: Double
|
let lon: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum DetachedFetchOutcome: Sendable {
|
|
||||||
case success(entries: [Entry], csv: String)
|
|
||||||
case torNotReady
|
|
||||||
case invalidData
|
|
||||||
case network(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let dependencies: GeoRelayDirectoryDependencies
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let cleanupState: CleanupState
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||||
|
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
private var retryTask: Task<Void, Never>?
|
||||||
private var retryAttempt: Int = 0
|
private var retryAttempt: Int = 0
|
||||||
private var isFetching: Bool = false
|
private var isFetching: Bool = false
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.dependencies = .live()
|
|
||||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
|
||||||
entries = loadLocalEntries()
|
entries = loadLocalEntries()
|
||||||
if dependencies.autoStart {
|
registerObservers()
|
||||||
registerObservers()
|
startRefreshTimer()
|
||||||
startRefreshTimer()
|
prefetchIfNeeded()
|
||||||
prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
deinit {
|
||||||
self.dependencies = dependencies
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
refreshTimer?.invalidate()
|
||||||
entries = loadLocalEntries()
|
retryTask?.cancel()
|
||||||
if dependencies.autoStart {
|
|
||||||
registerObservers()
|
|
||||||
startRefreshTimer()
|
|
||||||
prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
@@ -198,13 +83,13 @@ final class GeoRelayDirectory {
|
|||||||
func prefetchIfNeeded(force: Bool = false) {
|
func prefetchIfNeeded(force: Bool = false) {
|
||||||
guard !isFetching else { return }
|
guard !isFetching else { return }
|
||||||
|
|
||||||
let now = dependencies.now()
|
let now = Date()
|
||||||
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
|
||||||
if !force {
|
if !force {
|
||||||
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
} else if last != .distantPast,
|
} else if last != .distantPast,
|
||||||
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
// Skip forced fetches if we just refreshed moments ago.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,73 +103,51 @@ final class GeoRelayDirectory {
|
|||||||
isFetching = true
|
isFetching = true
|
||||||
|
|
||||||
let request = URLRequest(
|
let request = URLRequest(
|
||||||
url: dependencies.remoteURL,
|
url: remoteURL,
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||||
timeoutInterval: 15
|
timeoutInterval: 15
|
||||||
)
|
)
|
||||||
let awaitTorReady = dependencies.awaitTorReady
|
|
||||||
let fetchData = dependencies.makeFetchData()
|
|
||||||
|
|
||||||
Task { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
let outcome = await Self.fetchRemoteOutcome(
|
let ready = await TorManager.shared.awaitReady()
|
||||||
request: request,
|
if !ready {
|
||||||
awaitTorReady: awaitTorReady,
|
await self.handleFetchFailure(.torNotReady)
|
||||||
fetchData: fetchData
|
return
|
||||||
)
|
|
||||||
|
|
||||||
switch outcome {
|
|
||||||
case .success(let parsed, let csv):
|
|
||||||
self.handleFetchSuccess(entries: parsed, csv: csv)
|
|
||||||
case .torNotReady:
|
|
||||||
self.handleFetchFailure(.torNotReady)
|
|
||||||
case .invalidData:
|
|
||||||
self.handleFetchFailure(.invalidData)
|
|
||||||
case .network(let description):
|
|
||||||
self.handleFetchFailure(.network(description))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private static func fetchRemoteOutcome(
|
|
||||||
request: URLRequest,
|
|
||||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
|
||||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
|
||||||
) async -> DetachedFetchOutcome {
|
|
||||||
await Task.detached(priority: .utility) {
|
|
||||||
let ready = await awaitTorReady()
|
|
||||||
guard ready else { return .torNotReady }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try await fetchData(request)
|
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
return .invalidData
|
await self.handleFetchFailure(.invalidData)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed = Self.parseCSV(text)
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
guard !parsed.isEmpty else {
|
guard !parsed.isEmpty else {
|
||||||
return .invalidData
|
await self.handleFetchFailure(.invalidData)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return .success(entries: parsed, csv: text)
|
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||||
} catch {
|
} catch {
|
||||||
return .network(error.localizedDescription)
|
await self.handleFetchFailure(.network(error))
|
||||||
}
|
}
|
||||||
}.value
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum FetchFailure {
|
private enum FetchFailure {
|
||||||
case torNotReady
|
case torNotReady
|
||||||
case invalidData
|
case invalidData
|
||||||
case network(String)
|
case network(Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||||
entries = parsed
|
entries = parsed
|
||||||
persistCache(csv)
|
persistCache(csv)
|
||||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
isFetching = false
|
isFetching = false
|
||||||
retryAttempt = 0
|
retryAttempt = 0
|
||||||
@@ -298,8 +161,8 @@ final class GeoRelayDirectory {
|
|||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||||
case .invalidData:
|
case .invalidData:
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||||
case .network(let errorDescription):
|
case .network(let error):
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||||
}
|
}
|
||||||
isFetching = false
|
isFetching = false
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
@@ -308,34 +171,32 @@ final class GeoRelayDirectory {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func scheduleRetry() {
|
private func scheduleRetry() {
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
retryAttempt = min(retryAttempt + 1, 10)
|
||||||
let base = dependencies.retryInitialSeconds
|
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||||
let maxDelay = dependencies.retryMaxSeconds
|
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||||
let calculated = base * multiplier
|
let calculated = base * multiplier
|
||||||
let delay = min(maxDelay, max(base, calculated))
|
let delay = min(maxDelay, max(base, calculated))
|
||||||
|
|
||||||
cancelRetry()
|
cancelRetry()
|
||||||
cleanupState.retryTask = Task { [weak self] in
|
retryTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
await self.dependencies.retrySleep(delay)
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.prefetchIfNeeded(force: true)
|
self?.prefetchIfNeeded(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func cancelRetry() {
|
private func cancelRetry() {
|
||||||
cleanupState.retryTask?.cancel()
|
retryTask?.cancel()
|
||||||
cleanupState.retryTask = nil
|
retryTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = dependencies.cacheURL() else { return }
|
guard let url = cacheURL() else { return }
|
||||||
guard let data = text.data(using: .utf8) else { return }
|
|
||||||
do {
|
do {
|
||||||
try dependencies.writeData(data, url)
|
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
@@ -344,18 +205,22 @@ final class GeoRelayDirectory {
|
|||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = dependencies.cacheURL(),
|
if let cache = cacheURL(),
|
||||||
let data = dependencies.readData(cache),
|
let data = try? Data(contentsOf: cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
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 {
|
for url in bundleCandidates {
|
||||||
if let data = dependencies.readData(url),
|
if let data = try? Data(contentsOf: url),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
@@ -363,8 +228,8 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = dependencies.currentDirectoryPath(),
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
@@ -377,9 +242,10 @@ final class GeoRelayDirectory {
|
|||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if line.isEmpty { continue }
|
||||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
guard parts.count >= 3 else { continue }
|
guard parts.count >= 3 else { continue }
|
||||||
var host = parts[0]
|
var host = parts[0]
|
||||||
host = host.replacingOccurrences(of: "https://", with: "")
|
host = host.replacingOccurrences(of: "https://", with: "")
|
||||||
@@ -393,9 +259,25 @@ final class GeoRelayDirectory {
|
|||||||
return Array(result)
|
return Array(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cacheURL() -> URL? {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
// MARK: - Observers & Timers
|
||||||
private func registerObservers() {
|
private func registerObservers() {
|
||||||
let center = dependencies.notificationCenter
|
let center = NotificationCenter.default
|
||||||
|
|
||||||
let torReady = center.addObserver(
|
let torReady = center.addObserver(
|
||||||
forName: .TorDidBecomeReady,
|
forName: .TorDidBecomeReady,
|
||||||
@@ -407,26 +289,38 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded(force: true)
|
self.prefetchIfNeeded(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleanupState.observers.append(torReady)
|
observers.append(torReady)
|
||||||
|
|
||||||
if let activeNotificationName = dependencies.activeNotificationName {
|
#if os(iOS)
|
||||||
let didBecomeActive = center.addObserver(
|
let didBecomeActive = center.addObserver(
|
||||||
forName: activeNotificationName,
|
forName: UIApplication.didBecomeActiveNotification,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.prefetchIfNeeded()
|
self.prefetchIfNeeded()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cleanupState.observers.append(didBecomeActive)
|
|
||||||
}
|
}
|
||||||
|
observers.append(didBecomeActive)
|
||||||
|
#elseif os(macOS)
|
||||||
|
let didBecomeActive = center.addObserver(
|
||||||
|
forName: NSApplication.didBecomeActiveNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observers.append(didBecomeActive)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startRefreshTimer() {
|
private func startRefreshTimer() {
|
||||||
cleanupState.refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
let interval = dependencies.refreshCheckInterval
|
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||||
guard interval > 0 else { return }
|
guard interval > 0 else { return }
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
@@ -435,13 +329,9 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded()
|
self.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleanupState.refreshTimer = timer
|
refreshTimer = timer
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugRetryAttempt: Int { retryAttempt }
|
|
||||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
|
||||||
var debugObserverCount: Int { cleanupState.observers.count }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Distance
|
// MARK: - Distance
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
// MARK: - BitChat-over-Nostr Adapter
|
// MARK: - BitChat-over-Nostr Adapter
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ struct NostrProtocol {
|
|||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -109,7 +108,7 @@ struct NostrProtocol {
|
|||||||
teleported: Bool = false
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
if teleported {
|
if teleported {
|
||||||
@@ -126,24 +125,6 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
|
||||||
/// Must contain empty content and NO nickname tag
|
|
||||||
static func createGeohashPresenceEvent(
|
|
||||||
geohash: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let tags = [["g", geohash]]
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: tags,
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -152,7 +133,7 @@ struct NostrProtocol {
|
|||||||
nickname: String? = nil
|
nickname: String? = nil
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
@@ -528,26 +509,6 @@ struct NostrEvent: Codable {
|
|||||||
signed.sig = signatureHex
|
signed.sig = signatureHex
|
||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that the event ID and Schnorr signature match the content and pubkey.
|
|
||||||
/// Returns false when the signature is missing, malformed, or does not verify.
|
|
||||||
func isValidSignature() -> Bool {
|
|
||||||
guard let sig = sig,
|
|
||||||
let sigData = Data(hexString: sig),
|
|
||||||
let pubData = Data(hexString: pubkey),
|
|
||||||
sigData.count == 64,
|
|
||||||
pubData.count == 32,
|
|
||||||
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
|
|
||||||
let (expectedId, eventHash) = try? calculateEventId(),
|
|
||||||
expectedId == id
|
|
||||||
else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var messageBytes = [UInt8](eventHash)
|
|
||||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
|
||||||
return xonly.isValid(signature, for: &messageBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func calculateEventId() throws -> (String, Data) {
|
private func calculateEventId() throws -> (String, Data) {
|
||||||
let serialized = [
|
let serialized = [
|
||||||
|
|||||||
@@ -4,100 +4,6 @@ import Network
|
|||||||
import Combine
|
import Combine
|
||||||
import Tor
|
import Tor
|
||||||
|
|
||||||
protocol NostrRelayConnectionProtocol: AnyObject {
|
|
||||||
func resume()
|
|
||||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
|
|
||||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
|
||||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol NostrRelaySessionProtocol {
|
|
||||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {
|
|
||||||
private let base: URLSessionWebSocketTask
|
|
||||||
|
|
||||||
init(base: URLSessionWebSocketTask) {
|
|
||||||
self.base = base
|
|
||||||
}
|
|
||||||
|
|
||||||
func resume() {
|
|
||||||
base.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
|
||||||
base.cancel(with: closeCode, reason: reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
|
||||||
base.send(message, completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
|
||||||
base.receive(completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
|
||||||
base.sendPing(pongReceiveHandler: pongReceiveHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
|
||||||
let base: URLSession
|
|
||||||
|
|
||||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
|
||||||
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NostrRelayManagerDependencies {
|
|
||||||
var activationAllowed: () -> Bool
|
|
||||||
var userTorEnabled: () -> Bool
|
|
||||||
var hasMutualFavorites: () -> Bool
|
|
||||||
var hasLocationPermission: () -> Bool
|
|
||||||
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
|
||||||
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
|
||||||
var torEnforced: () -> Bool
|
|
||||||
var torIsReady: () -> Bool
|
|
||||||
var torIsForeground: () -> Bool
|
|
||||||
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
|
||||||
var makeSession: () -> NostrRelaySessionProtocol
|
|
||||||
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
|
||||||
var now: () -> Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension NostrRelayManagerDependencies {
|
|
||||||
@MainActor
|
|
||||||
static func live() -> Self {
|
|
||||||
Self(
|
|
||||||
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
|
||||||
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
|
||||||
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
|
||||||
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
|
||||||
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
|
||||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
|
||||||
torEnforced: { TorManager.shared.torEnforced },
|
|
||||||
torIsReady: { TorManager.shared.isReady },
|
|
||||||
torIsForeground: { TorManager.shared.isForeground() },
|
|
||||||
awaitTorReady: { completion in
|
|
||||||
Task.detached {
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
|
||||||
await MainActor.run {
|
|
||||||
completion(ready)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
|
|
||||||
scheduleAfter: { delay, action in
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
|
||||||
},
|
|
||||||
now: Date.init
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages WebSocket connections to Nostr relays
|
/// Manages WebSocket connections to Nostr relays
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NostrRelayManager: ObservableObject {
|
final class NostrRelayManager: ObservableObject {
|
||||||
@@ -135,11 +41,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
private let dependencies: NostrRelayManagerDependencies
|
|
||||||
private var allowDefaultRelays: Bool = false
|
private var allowDefaultRelays: Bool = false
|
||||||
private var hasMutualFavorites: Bool = false
|
private var hasMutualFavorites: Bool = false
|
||||||
private var hasLocationPermission: Bool = false
|
private var hasLocationPermission: Bool = false
|
||||||
private var connections: [String: NostrRelayConnectionProtocol] = [:]
|
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||||
@@ -164,7 +69,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
private let decoder = JSONDecoder()
|
||||||
|
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||||
|
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||||
@@ -172,17 +79,18 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||||
|
|
||||||
|
// Reconnection timer
|
||||||
|
private var reconnectionTimer: Timer?
|
||||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.dependencies = .live()
|
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||||
hasLocationPermission = dependencies.hasLocationPermission()
|
|
||||||
applyDefaultRelayPolicy(force: true)
|
applyDefaultRelayPolicy(force: true)
|
||||||
// Deterministic JSON shape for outbound requests
|
// Deterministic JSON shape for outbound requests
|
||||||
self.encoder.outputFormatting = .sortedKeys
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
dependencies.mutualFavoritesPublisher
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] favorites in
|
.sink { [weak self] favorites in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -190,34 +98,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
self.applyDefaultRelayPolicy()
|
self.applyDefaultRelayPolicy()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
dependencies.locationPermissionPublisher
|
LocationChannelManager.shared.$permissionState
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] state in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let authorized = (state == .authorized)
|
|
||||||
if authorized == self.hasLocationPermission { return }
|
|
||||||
self.hasLocationPermission = authorized
|
|
||||||
self.applyDefaultRelayPolicy()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal init(dependencies: NostrRelayManagerDependencies) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
|
||||||
hasLocationPermission = dependencies.hasLocationPermission()
|
|
||||||
applyDefaultRelayPolicy(force: true)
|
|
||||||
// Deterministic JSON shape for outbound requests
|
|
||||||
self.encoder.outputFormatting = .sortedKeys
|
|
||||||
dependencies.mutualFavoritesPublisher
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] favorites in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.hasMutualFavorites = !favorites.isEmpty
|
|
||||||
self.applyDefaultRelayPolicy()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
dependencies.locationPermissionPublisher
|
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] state in
|
.sink { [weak self] state in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -232,18 +113,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard networkService.activationAllowed else { return }
|
||||||
if shouldUseTor {
|
if shouldUseTor {
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
Task.detached {
|
||||||
guard let self = self else { return }
|
let ready = await TorManager.shared.awaitReady()
|
||||||
if !ready {
|
await MainActor.run {
|
||||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
if !ready {
|
||||||
return
|
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||||
}
|
return
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
}
|
||||||
for relay in self.relays {
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||||
self.connectToRelay(relay.url)
|
for relay in self.relays {
|
||||||
|
self.connectToRelay(relay.url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -270,14 +153,15 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
func ensureConnections(to relayUrls: [String]) {
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard networkService.activationAllowed else { return }
|
||||||
let targets = allowedRelayList(from: relayUrls)
|
let targets = allowedRelayList(from: relayUrls)
|
||||||
guard !targets.isEmpty else { return }
|
guard !targets.isEmpty else { return }
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if ready { self.ensureConnections(to: relayUrls) }
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -294,12 +178,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard networkService.activationAllowed else { return }
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer sends until Tor is ready to avoid premature queueing
|
// Defer sends until Tor is ready to avoid premature queueing
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if ready { self.sendEvent(event, to: relayUrls) }
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -371,21 +256,24 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
onEOSE: (() -> Void)? = nil
|
onEOSE: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard networkService.activationAllowed else { return }
|
||||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||||
let now = dependencies.now()
|
let now = Date()
|
||||||
if messageHandlers[id] != nil {
|
if messageHandlers[id] != nil {
|
||||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscribeCoalesce[id] = now
|
subscribeCoalesce[id] = now
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if ready {
|
let ready = await TorManager.shared.awaitReady()
|
||||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
|
await MainActor.run {
|
||||||
|
if ready {
|
||||||
|
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -412,7 +300,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
for url in urls where !existingSet.contains(url) {
|
for url in urls where !existingSet.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
}
|
}
|
||||||
for url in urls {
|
for url in candidateUrls {
|
||||||
var map = self.pendingSubscriptions[url] ?? [:]
|
var map = self.pendingSubscriptions[url] ?? [:]
|
||||||
map[id] = messageString
|
map[id] = messageString
|
||||||
self.pendingSubscriptions[url] = map
|
self.pendingSubscriptions[url] = map
|
||||||
@@ -461,7 +349,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
existing.insert(url)
|
existing.insert(url)
|
||||||
}
|
}
|
||||||
if dependencies.activationAllowed() {
|
if networkService.activationAllowed {
|
||||||
ensureConnections(to: Self.defaultRelays)
|
ensureConnections(to: Self.defaultRelays)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -471,7 +359,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
connections.removeValue(forKey: url)
|
connections.removeValue(forKey: url)
|
||||||
subscriptions.removeValue(forKey: url)
|
subscriptions.removeValue(forKey: url)
|
||||||
pendingSubscriptions.removeValue(forKey: url)
|
|
||||||
}
|
}
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
for index in (0..<messageQueue.count).reversed() {
|
for index in (0..<messageQueue.count).reversed() {
|
||||||
@@ -516,9 +403,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Send unsubscribe to all relays
|
// Send unsubscribe to all relays
|
||||||
for (relayUrl, connection) in connections {
|
for (relayUrl, connection) in connections {
|
||||||
if subscriptions[relayUrl]?.contains(id) == true {
|
if subscriptions[relayUrl]?.contains(id) == true {
|
||||||
subscriptions[relayUrl]?.remove(id)
|
|
||||||
connection.send(.string(messageString)) { _ in
|
connection.send(.string(messageString)) { _ in
|
||||||
// Local state is cleared before sending so callers can re-subscribe immediately.
|
Task { @MainActor in
|
||||||
|
self.subscriptions[relayUrl]?.remove(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,14 +416,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func connectToRelay(_ urlString: String) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard networkService.activationAllowed else { return }
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,16 +438,19 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Attempting to connect to Nostr relay via the proxied session
|
// Attempting to connect to Nostr relay via the proxied session
|
||||||
|
|
||||||
// If Tor is enforced but not ready, delay connection until it is.
|
// If Tor is enforced but not ready, delay connection until it is.
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if ready { self.connectToRelay(urlString) }
|
let ready = await TorManager.shared.awaitReady()
|
||||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
await MainActor.run {
|
||||||
|
if ready { self.connectToRelay(urlString) }
|
||||||
|
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = dependencies.makeSession()
|
let session = TorURLSession.shared.session
|
||||||
let task = session.webSocketTask(with: url)
|
let task = session.webSocketTask(with: url)
|
||||||
|
|
||||||
connections[urlString] = task
|
connections[urlString] = task
|
||||||
@@ -607,7 +498,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
pendingSubscriptions[relayUrl] = nil
|
pendingSubscriptions[relayUrl] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
|
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
|
||||||
task.receive { [weak self] result in
|
task.receive { [weak self] result in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -617,7 +508,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
guard let parsed = ParsedInbound(message) else { return }
|
guard let parsed = ParsedInbound(message) else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.handleParsedMessage(parsed, from: relayUrl)
|
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +572,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) {
|
||||||
let req = NostrRequest.event(event)
|
let req = NostrRequest.event(event)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -713,11 +604,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].isConnected = isConnected
|
relays[index].isConnected = isConnected
|
||||||
relays[index].lastError = error
|
relays[index].lastError = error
|
||||||
if isConnected {
|
if isConnected {
|
||||||
relays[index].lastConnectedAt = dependencies.now()
|
relays[index].lastConnectedAt = Date()
|
||||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
} else {
|
} else {
|
||||||
relays[index].lastDisconnectedAt = dependencies.now()
|
relays[index].lastDisconnectedAt = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
@@ -733,7 +624,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||||
// If networking is disallowed, do not schedule reconnection
|
// If networking is disallowed, do not schedule reconnection
|
||||||
if !dependencies.activationAllowed() {
|
if !networkService.activationAllowed {
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
@@ -778,21 +669,19 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
maxBackoffInterval
|
maxBackoffInterval
|
||||||
)
|
)
|
||||||
|
|
||||||
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
|
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
|
||||||
relays[index].nextReconnectTime = nextReconnectTime
|
relays[index].nextReconnectTime = nextReconnectTime
|
||||||
|
|
||||||
|
|
||||||
// Schedule reconnection with exponential backoff
|
// Schedule reconnection with exponential backoff
|
||||||
let gen = connectionGeneration
|
let gen = connectionGeneration
|
||||||
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
guard let self = self else { return }
|
||||||
guard let self = self else { return }
|
// Ignore stale scheduled reconnects from a previous generation
|
||||||
// Ignore stale scheduled reconnects from a previous generation
|
guard gen == self.connectionGeneration else { return }
|
||||||
guard gen == self.connectionGeneration else { return }
|
// Check if we should still reconnect (relay might have been removed)
|
||||||
// Check if we should still reconnect (relay might have been removed)
|
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
self.connectToRelay(relayUrl)
|
||||||
self.connectToRelay(relayUrl)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,7 +695,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Reset reconnection attempts
|
// Reset reconnection attempts
|
||||||
relays[index].reconnectAttempts = 0
|
relays[index].reconnectAttempts = 0
|
||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
relays[index].lastError = nil
|
|
||||||
|
|
||||||
// Disconnect if connected
|
// Disconnect if connected
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connections[relayUrl] {
|
||||||
@@ -827,20 +715,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
nextReconnectTime: relay.nextReconnectTime)
|
nextReconnectTime: relay.nextReconnectTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugPendingMessageQueueCount: Int {
|
|
||||||
messageQueueLock.lock()
|
|
||||||
defer { messageQueueLock.unlock() }
|
|
||||||
return messageQueue.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
|
||||||
pendingSubscriptions[relayUrl]?.count ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func debugFlushMessageQueue() {
|
|
||||||
flushMessageQueue(for: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset all relay connections
|
/// Reset all relay connections
|
||||||
func resetAllConnections() {
|
func resetAllConnections() {
|
||||||
@@ -893,8 +767,7 @@ private enum ParsedInbound {
|
|||||||
if array.count >= 3,
|
if array.count >= 3,
|
||||||
let subId = array[1] as? String,
|
let subId = array[1] as? String,
|
||||||
let eventDict = array[2] as? [String: Any],
|
let eventDict = array[2] as? [String: Any],
|
||||||
let event = try? NostrEvent(from: eventDict),
|
let event = try? NostrEvent(from: eventDict) {
|
||||||
event.isValidSignature() {
|
|
||||||
self = .event(subId: subId, event: event)
|
self = .event(subId: subId, event: event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1014,10 +887,10 @@ struct NostrFilter: Encodable {
|
|||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
// For location channels: geohash-scoped ephemeral events (kind 20000)
|
||||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
var filter = NostrFilter()
|
var filter = NostrFilter()
|
||||||
filter.kinds = [20000, 20001]
|
filter.kinds = [20000]
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["g": [geohash]]
|
filter.tagFilters = ["g": [geohash]]
|
||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
|
|||||||
+68
-13
@@ -5,15 +5,70 @@
|
|||||||
// Binary encoding utilities for efficient protocol messages
|
// Binary encoding utilities for efficient protocol messages
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.Date
|
import CryptoKit
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
// MARK: Writing
|
// MARK: Writing
|
||||||
|
|
||||||
@inlinable public mutating func appendUInt8(_ value: UInt8) {
|
@inlinable mutating func appendUInt8(_ value: UInt8) {
|
||||||
self.append(value)
|
self.append(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +90,7 @@ extension Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendString(_ string: String, maxLength: Int = 255) {
|
mutating func appendString(_ string: String, maxLength: Int = 255) {
|
||||||
guard let data = string.data(using: .utf8) else { return }
|
guard let data = string.data(using: .utf8) else { return }
|
||||||
let length = Swift.min(data.count, maxLength)
|
let length = Swift.min(data.count, maxLength)
|
||||||
|
|
||||||
@@ -48,7 +103,7 @@ extension Data {
|
|||||||
self.append(data.prefix(length))
|
self.append(data.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
||||||
let length = Swift.min(data.count, maxLength)
|
let length = Swift.min(data.count, maxLength)
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -60,12 +115,12 @@ extension Data {
|
|||||||
self.append(data.prefix(length))
|
self.append(data.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendDate(_ date: Date) {
|
mutating func appendDate(_ date: Date) {
|
||||||
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
||||||
self.appendUInt64(timestamp)
|
self.appendUInt64(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendUUID(_ uuid: String) {
|
mutating func appendUUID(_ uuid: String) {
|
||||||
// Convert UUID string to 16 bytes
|
// Convert UUID string to 16 bytes
|
||||||
var uuidData = Data(count: 16)
|
var uuidData = Data(count: 16)
|
||||||
|
|
||||||
@@ -86,7 +141,7 @@ extension Data {
|
|||||||
|
|
||||||
// MARK: Reading
|
// MARK: Reading
|
||||||
|
|
||||||
@inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
|
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
|
||||||
guard offset >= 0 && offset < self.count else { return nil }
|
guard offset >= 0 && offset < self.count else { return nil }
|
||||||
let value = self[offset]
|
let value = self[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
@@ -120,7 +175,7 @@ extension Data {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
||||||
let length: Int
|
let length: Int
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -139,7 +194,7 @@ extension Data {
|
|||||||
return String(data: stringData, encoding: .utf8)
|
return String(data: stringData, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
||||||
let length: Int
|
let length: Int
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -158,12 +213,12 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readDate(at offset: inout Int) -> Date? {
|
func readDate(at offset: inout Int) -> Date? {
|
||||||
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
||||||
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readUUID(at offset: inout Int) -> String? {
|
func readUUID(at offset: inout Int) -> String? {
|
||||||
guard offset + 16 <= self.count else { return nil }
|
guard offset + 16 <= self.count else { return nil }
|
||||||
|
|
||||||
let uuidData = self[offset..<offset + 16]
|
let uuidData = self[offset..<offset + 16]
|
||||||
@@ -184,7 +239,7 @@ extension Data {
|
|||||||
return result.uppercased()
|
return result.uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
||||||
guard offset + count <= self.count else { return nil }
|
guard offset + count <= self.count else { return nil }
|
||||||
|
|
||||||
let data = self[offset..<offset + count]
|
let data = self[offset..<offset + count]
|
||||||
+48
-43
@@ -88,31 +88,40 @@
|
|||||||
/// - Platform-optimized byte swapping
|
/// - Platform-optimized byte swapping
|
||||||
///
|
///
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import class Foundation.NSData
|
import BitLogger
|
||||||
private import BitLogger
|
|
||||||
|
extension Data {
|
||||||
|
func trimmingNullBytes() -> Data {
|
||||||
|
// Find the first null byte
|
||||||
|
if let nullIndex = self.firstIndex(of: 0) {
|
||||||
|
return self.prefix(nullIndex)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Implements binary encoding and decoding for BitChat protocol messages.
|
/// Implements binary encoding and decoding for BitChat protocol messages.
|
||||||
/// Provides static methods for converting between BitchatPacket objects and
|
/// Provides static methods for converting between BitchatPacket objects and
|
||||||
/// their binary wire format representation.
|
/// their binary wire format representation.
|
||||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
public struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
public static let v1HeaderSize = 14
|
static let v1HeaderSize = 14
|
||||||
static let v2HeaderSize = 16
|
static let v2HeaderSize = 16
|
||||||
public static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
public static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
public static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
// Field offsets within packet header
|
// Field offsets within packet header
|
||||||
public struct Offsets {
|
struct Offsets {
|
||||||
static let version = 0
|
static let version = 0
|
||||||
static let type = 1
|
static let type = 1
|
||||||
static let ttl = 2
|
static let ttl = 2
|
||||||
static let timestamp = 3
|
static let timestamp = 3
|
||||||
public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func headerSize(for version: UInt8) -> Int? {
|
static func headerSize(for version: UInt8) -> Int? {
|
||||||
switch version {
|
switch version {
|
||||||
case 1: return v1HeaderSize
|
case 1: return v1HeaderSize
|
||||||
case 2: return v2HeaderSize
|
case 2: return v2HeaderSize
|
||||||
@@ -124,12 +133,11 @@ public struct BinaryProtocol {
|
|||||||
return version == 2 ? 4 : 2
|
return version == 2 ? 4 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Flags {
|
struct Flags {
|
||||||
public static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
public static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
public static let isCompressed: UInt8 = 0x04
|
static let isCompressed: UInt8 = 0x04
|
||||||
public static let hasRoute: UInt8 = 0x08
|
static let hasRoute: UInt8 = 0x08
|
||||||
static let isRSR: UInt8 = 0x10
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
@@ -153,9 +161,7 @@ public struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
|
let originalRoute = packet.route ?? []
|
||||||
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
|
|
||||||
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
|
|
||||||
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
||||||
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
||||||
if hop.count == senderIDSize { return hop }
|
if hop.count == senderIDSize { return hop }
|
||||||
@@ -169,14 +175,13 @@ public struct BinaryProtocol {
|
|||||||
let hasRoute = !sanitizedRoute.isEmpty
|
let hasRoute = !sanitizedRoute.isEmpty
|
||||||
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
||||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
// payloadLength in header is payload-only (does NOT include route bytes)
|
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
|
||||||
let payloadDataSize = payload.count + originalSizeFieldBytes
|
|
||||||
|
|
||||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
|
|
||||||
guard let headerSize = headerSize(for: version) else { return nil }
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength
|
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||||
let estimatedPayload = payloadDataSize
|
let estimatedPayload = payloadDataSize
|
||||||
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||||
var data = Data()
|
var data = Data()
|
||||||
@@ -194,11 +199,9 @@ public struct BinaryProtocol {
|
|||||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
if isCompressed { flags |= Flags.isCompressed }
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
// HAS_ROUTE is only valid for v2+ packets
|
if hasRoute { flags |= Flags.hasRoute }
|
||||||
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
|
|
||||||
if packet.isRSR { flags |= Flags.isRSR }
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
let length = UInt32(payloadDataSize)
|
let length = UInt32(payloadDataSize)
|
||||||
for shift in stride(from: 24, through: 0, by: -8) {
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
@@ -257,7 +260,7 @@ public struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
public static func decode(_ data: Data) -> BitchatPacket? {
|
static func decode(_ data: Data) -> BitchatPacket? {
|
||||||
// Try decode as-is first (robust when padding wasn't applied)
|
// Try decode as-is first (robust when padding wasn't applied)
|
||||||
if let pkt = decodeCore(data) { return pkt }
|
if let pkt = decodeCore(data) { return pkt }
|
||||||
// If that fails, try after removing padding
|
// If that fails, try after removing padding
|
||||||
@@ -320,10 +323,7 @@ public struct BinaryProtocol {
|
|||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
|
||||||
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
|
|
||||||
let isRSR = (flags & Flags.isRSR) != 0
|
|
||||||
|
|
||||||
let payloadLength: Int
|
let payloadLength: Int
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
guard let len = read32() else { return nil }
|
guard let len = read32() else { return nil }
|
||||||
@@ -334,7 +334,6 @@ public 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 }
|
||||||
|
|
||||||
@@ -344,24 +343,27 @@ public struct BinaryProtocol {
|
|||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
|
|
||||||
var route: [Data]? = nil
|
var route: [Data]? = nil
|
||||||
if hasRoute {
|
var remainingPayloadBytes = payloadLength
|
||||||
guard let routeCount = read8() else { return nil }
|
|
||||||
|
if (flags & Flags.hasRoute) != 0 {
|
||||||
|
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
|
||||||
|
remainingPayloadBytes -= 1
|
||||||
if routeCount > 0 {
|
if routeCount > 0 {
|
||||||
var hops: [Data] = []
|
var hops: [Data] = []
|
||||||
for _ in 0..<Int(routeCount) {
|
for _ in 0..<Int(routeCount) {
|
||||||
guard let hop = readData(senderIDSize) else { return nil }
|
guard remainingPayloadBytes >= senderIDSize,
|
||||||
|
let hop = readData(senderIDSize) else { return nil }
|
||||||
|
remainingPayloadBytes -= senderIDSize
|
||||||
hops.append(hop)
|
hops.append(hop)
|
||||||
}
|
}
|
||||||
route = hops
|
route = hops
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
|
||||||
let originalSize: Int
|
let originalSize: Int
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
guard let rawSize = read32() else { return nil }
|
guard let rawSize = read32() else { return nil }
|
||||||
@@ -370,9 +372,11 @@ public struct BinaryProtocol {
|
|||||||
guard let rawSize = read16() else { return nil }
|
guard let rawSize = read16() else { return nil }
|
||||||
originalSize = Int(rawSize)
|
originalSize = Int(rawSize)
|
||||||
}
|
}
|
||||||
|
remainingPayloadBytes -= lengthFieldBytes
|
||||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||||
let compressedSize = payloadLength - lengthFieldBytes
|
let compressedSize = remainingPayloadBytes
|
||||||
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
remainingPayloadBytes = 0
|
||||||
|
|
||||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
guard compressionRatio <= 50_000.0 else {
|
guard compressionRatio <= 50_000.0 else {
|
||||||
@@ -384,7 +388,9 @@ public struct BinaryProtocol {
|
|||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard let rawPayload = readData(payloadLength) else { return nil }
|
guard remainingPayloadBytes >= 0,
|
||||||
|
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
||||||
|
remainingPayloadBytes = 0
|
||||||
payload = rawPayload
|
payload = rawPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,8 +411,7 @@ public struct BinaryProtocol {
|
|||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
version: version,
|
version: version,
|
||||||
route: route,
|
route: route
|
||||||
isRSR: isRSR
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
|
||||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||||
|
|||||||
@@ -60,7 +60,40 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
|
||||||
|
// MARK: - Message Types
|
||||||
|
|
||||||
|
/// Simplified BitChat protocol message types.
|
||||||
|
/// Reduced from 24 types to just 6 essential ones.
|
||||||
|
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
||||||
|
enum MessageType: UInt8 {
|
||||||
|
// Public messages (unencrypted)
|
||||||
|
case announce = 0x01 // "I'm here" with nickname
|
||||||
|
case message = 0x02 // Public chat message
|
||||||
|
case leave = 0x03 // "I'm leaving"
|
||||||
|
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||||
|
|
||||||
|
// Noise encryption
|
||||||
|
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||||
|
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||||
|
|
||||||
|
// Fragmentation (simplified)
|
||||||
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .announce: return "announce"
|
||||||
|
case .message: return "message"
|
||||||
|
case .leave: return "leave"
|
||||||
|
case .requestSync: return "requestSync"
|
||||||
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
|
case .fragment: return "fragment"
|
||||||
|
case .fileTransfer: return "fileTransfer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Noise Payload Types
|
// MARK: - Noise Payload Types
|
||||||
|
|
||||||
@@ -98,6 +131,35 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
case failed(Error) // Handshake failed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
|
// Delivery status for messages
|
||||||
|
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||||
|
case sending
|
||||||
|
case sent // Left our device
|
||||||
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
|
case read(by: String, at: Date) // Seen by recipient
|
||||||
|
case failed(reason: String)
|
||||||
|
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||||
|
|
||||||
|
var displayText: String {
|
||||||
|
switch self {
|
||||||
|
case .sending:
|
||||||
|
return "Sending..."
|
||||||
|
case .sent:
|
||||||
|
return "Sent"
|
||||||
|
case .delivered(let nickname, _):
|
||||||
|
return "Delivered to \(nickname)"
|
||||||
|
case .read(let nickname, _):
|
||||||
|
return "Read by \(nickname)"
|
||||||
|
case .failed(let reason):
|
||||||
|
return "Failed: \(reason)"
|
||||||
|
case .partiallyDelivered(let reached, let total):
|
||||||
|
return "Delivered to \(reached)/\(total)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -191,22 +191,5 @@ enum MimeType: CaseIterable, Hashable {
|
|||||||
extension MimeType {
|
extension MimeType {
|
||||||
enum Category: String {
|
enum Category: String {
|
||||||
case audio, image, file
|
case audio, image, file
|
||||||
|
|
||||||
/// Ends with a space
|
|
||||||
var messagePrefix: String {
|
|
||||||
switch self {
|
|
||||||
case .audio: "[voice] "
|
|
||||||
case .image: "[image] "
|
|
||||||
case .file: "[file] "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaDir: String {
|
|
||||||
switch self {
|
|
||||||
case .audio: "voicenotes"
|
|
||||||
case .image: "images"
|
|
||||||
case .file: "files"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
/// Result of command processing
|
/// Result of command processing
|
||||||
enum CommandResult {
|
enum CommandResult {
|
||||||
@@ -60,11 +59,22 @@ final class CommandProcessor {
|
|||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
|
||||||
|
/// Backward-compatible property for existing code
|
||||||
|
weak var chatViewModel: CommandContextProvider? {
|
||||||
|
get { contextProvider }
|
||||||
|
set { contextProvider = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
self.contextProvider = contextProvider
|
self.contextProvider = contextProvider
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backward-compatible initializer
|
||||||
|
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
|
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
|
||||||
|
}
|
||||||
|
|
||||||
/// Process a command string
|
/// Process a command string
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -167,7 +177,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.trimmed
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /\(command) <nickname>")
|
return .error(message: "usage: /\(command) <nickname>")
|
||||||
}
|
}
|
||||||
@@ -210,7 +220,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleBlock(_ args: String) -> CommandResult {
|
private func handleBlock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
if targetName.isEmpty {
|
if targetName.isEmpty {
|
||||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||||
@@ -285,7 +295,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleUnblock(_ args: String) -> CommandResult {
|
private func handleUnblock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /unblock <nickname>")
|
return .error(message: "usage: /unblock <nickname>")
|
||||||
}
|
}
|
||||||
@@ -312,7 +322,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
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,5 +1,4 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
@@ -32,6 +31,9 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
|
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||||
|
|||||||
@@ -83,9 +83,6 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
var map = participants[geohash] ?? [:]
|
var map = participants[geohash] ?? [:]
|
||||||
map[key] = Date()
|
map[key] = Date()
|
||||||
participants[geohash] = map
|
participants[geohash] = map
|
||||||
|
|
||||||
// Always notify observers that state has changed so counts in UI update
|
|
||||||
objectWillChange.send()
|
|
||||||
|
|
||||||
// Only refresh visible list if this geohash is currently active
|
// Only refresh visible list if this geohash is currently active
|
||||||
if activeGeohash == geohash {
|
if activeGeohash == geohash {
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashPresenceService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
|
|
||||||
// to geohash location channels.
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import BitLogger
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
protocol GeohashPresenceTimerProtocol: AnyObject {
|
|
||||||
var isValid: Bool { get }
|
|
||||||
func invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
|
|
||||||
private let base: Timer
|
|
||||||
|
|
||||||
init(base: Timer) {
|
|
||||||
self.base = base
|
|
||||||
}
|
|
||||||
|
|
||||||
var isValid: Bool { base.isValid }
|
|
||||||
|
|
||||||
func invalidate() {
|
|
||||||
base.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
|
||||||
///
|
|
||||||
/// Behavior:
|
|
||||||
/// - Monitors location changes via LocationStateManager
|
|
||||||
/// - Broadcasts Kind 20001 events to low-precision geohash channels
|
|
||||||
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
|
|
||||||
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
|
|
||||||
@MainActor
|
|
||||||
final class GeohashPresenceService: ObservableObject {
|
|
||||||
static let shared = GeohashPresenceService()
|
|
||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
|
||||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
|
||||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
|
||||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
|
||||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
|
||||||
private let torIsReady: () -> Bool
|
|
||||||
private let torIsForeground: () -> Bool
|
|
||||||
private let deriveIdentity: (String) throws -> NostrIdentity
|
|
||||||
private let relayLookup: (String, Int) -> [String]
|
|
||||||
private let relaySender: (NostrEvent, [String]) -> Void
|
|
||||||
private let sleeper: (UInt64) async -> Void
|
|
||||||
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
|
|
||||||
|
|
||||||
// MARK: - Constants
|
|
||||||
|
|
||||||
// Loop interval range in seconds
|
|
||||||
private let loopMinInterval: TimeInterval
|
|
||||||
private let loopMaxInterval: TimeInterval
|
|
||||||
|
|
||||||
// Per-broadcast decorrelation delay range in seconds
|
|
||||||
private let burstMinDelay: TimeInterval
|
|
||||||
private let burstMaxDelay: TimeInterval
|
|
||||||
|
|
||||||
// Privacy: Only broadcast to these levels
|
|
||||||
private let allowedPrecisions: Set<Int> = [
|
|
||||||
GeohashChannelLevel.region.precision, // 2
|
|
||||||
GeohashChannelLevel.province.precision, // 4
|
|
||||||
GeohashChannelLevel.city.precision // 5
|
|
||||||
]
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
let idBridge = NostrIdentityBridge()
|
|
||||||
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
|
||||||
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
|
||||||
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
|
||||||
.map { _ in () }
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
self.torIsReady = { TorManager.shared.isReady }
|
|
||||||
self.torIsForeground = { TorManager.shared.isForeground() }
|
|
||||||
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
|
|
||||||
self.relayLookup = { geohash, count in
|
|
||||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
|
||||||
}
|
|
||||||
self.relaySender = { event, relays in
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
|
||||||
}
|
|
||||||
self.sleeper = { nanoseconds in
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
}
|
|
||||||
self.scheduleTimer = { interval, action in
|
|
||||||
GeohashPresenceTimerAdapter(
|
|
||||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
self.loopMinInterval = 40.0
|
|
||||||
self.loopMaxInterval = 80.0
|
|
||||||
self.burstMinDelay = 2.0
|
|
||||||
self.burstMaxDelay = 5.0
|
|
||||||
setupObservers()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal init(
|
|
||||||
availableChannelsProvider: @escaping () -> [GeohashChannel],
|
|
||||||
locationChanges: AnyPublisher<[GeohashChannel], Never>,
|
|
||||||
torReadyPublisher: AnyPublisher<Void, Never>,
|
|
||||||
torIsReady: @escaping () -> Bool,
|
|
||||||
torIsForeground: @escaping () -> Bool,
|
|
||||||
deriveIdentity: @escaping (String) throws -> NostrIdentity,
|
|
||||||
relayLookup: @escaping (String, Int) -> [String],
|
|
||||||
relaySender: @escaping (NostrEvent, [String]) -> Void,
|
|
||||||
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
|
|
||||||
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
|
|
||||||
GeohashPresenceTimerAdapter(
|
|
||||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
loopMinInterval: TimeInterval = 40.0,
|
|
||||||
loopMaxInterval: TimeInterval = 80.0,
|
|
||||||
burstMinDelay: TimeInterval = 2.0,
|
|
||||||
burstMaxDelay: TimeInterval = 5.0
|
|
||||||
) {
|
|
||||||
self.availableChannelsProvider = availableChannelsProvider
|
|
||||||
self.locationChanges = locationChanges
|
|
||||||
self.torReadyPublisher = torReadyPublisher
|
|
||||||
self.torIsReady = torIsReady
|
|
||||||
self.torIsForeground = torIsForeground
|
|
||||||
self.deriveIdentity = deriveIdentity
|
|
||||||
self.relayLookup = relayLookup
|
|
||||||
self.relaySender = relaySender
|
|
||||||
self.sleeper = sleeper
|
|
||||||
self.scheduleTimer = scheduleTimer
|
|
||||||
self.loopMinInterval = loopMinInterval
|
|
||||||
self.loopMaxInterval = loopMaxInterval
|
|
||||||
self.burstMinDelay = burstMinDelay
|
|
||||||
self.burstMaxDelay = burstMaxDelay
|
|
||||||
setupObservers()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the service (safe to call multiple times)
|
|
||||||
func start() {
|
|
||||||
SecureLogger.info("Presence: service starting...", category: .session)
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupObservers() {
|
|
||||||
// Monitor location channel changes
|
|
||||||
locationChanges
|
|
||||||
.dropFirst()
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.handleLocationChange()
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
|
|
||||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
|
||||||
torReadyPublisher
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.handleConnectivityChange()
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLocationChange() {
|
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
|
||||||
// to announce presence in the new zone, then reset the loop.
|
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
|
||||||
heartbeatTimer?.invalidate()
|
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
|
||||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.performHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleConnectivityChange() {
|
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
|
||||||
// If we were waiting for network, do it now
|
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleNextHeartbeat() {
|
|
||||||
heartbeatTimer?.invalidate()
|
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
|
||||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.performHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func performHeartbeat() {
|
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
|
||||||
defer { scheduleNextHeartbeat() }
|
|
||||||
|
|
||||||
// 1. Check preconditions
|
|
||||||
guard torIsReady() else {
|
|
||||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
|
||||||
if !torIsForeground() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get channels
|
|
||||||
let channels = availableChannelsProvider()
|
|
||||||
guard !channels.isEmpty else { return }
|
|
||||||
|
|
||||||
// 3. Filter and broadcast
|
|
||||||
// We use Task + sleep for decorrelation to allow the main runloop to proceed
|
|
||||||
for channel in channels {
|
|
||||||
// Check privacy restriction
|
|
||||||
if !self.allowedPrecisions.contains(channel.geohash.count) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch independent task for each channel's delay
|
|
||||||
Task { @MainActor in
|
|
||||||
// Random delay for decorrelation
|
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
await self.sleeper(nanoseconds)
|
|
||||||
|
|
||||||
self.broadcastPresence(for: channel.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func broadcastPresence(for geohash: String) {
|
|
||||||
do {
|
|
||||||
guard let identity = try? deriveIdentity(geohash) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: geohash,
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send via RelayManager
|
|
||||||
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
|
||||||
|
|
||||||
if !targetRelays.isEmpty {
|
|
||||||
relaySender(event, targetRelays)
|
|
||||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,29 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
|
protocol KeychainManagerProtocol {
|
||||||
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||||
|
func getIdentityKey(forKey key: String) -> Data?
|
||||||
|
func deleteIdentityKey(forKey key: String) -> Bool
|
||||||
|
func deleteAllKeychainData() -> Bool
|
||||||
|
|
||||||
|
func secureClear(_ data: inout Data)
|
||||||
|
func secureClear(_ string: inout String)
|
||||||
|
|
||||||
|
func verifyIdentityKeyExists() -> Bool
|
||||||
|
|
||||||
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
|
/// Save data with a custom service name
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||||
|
/// Load data from a custom service
|
||||||
|
func load(key: String, service: String) -> Data?
|
||||||
|
/// Delete data from a custom service
|
||||||
|
func delete(key: String, service: String)
|
||||||
|
}
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
@@ -35,181 +54,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BCH-01-009: Methods with Proper Error Classification
|
|
||||||
|
|
||||||
/// Get identity key with detailed result for proper error handling
|
|
||||||
/// Distinguishes between missing keys (expected) and critical failures
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
let fullKey = "identity_\(key)"
|
|
||||||
return retrieveDataWithResult(forKey: fullKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save identity key with detailed result and retry logic for transient errors
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
let fullKey = "identity_\(key)"
|
|
||||||
return saveDataWithResult(keyData, forKey: fullKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to save data with detailed result and retry for transient errors
|
|
||||||
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
|
|
||||||
// Delete any existing item first to ensure clean state
|
|
||||||
_ = delete(forKey: key)
|
|
||||||
|
|
||||||
// Build base query
|
|
||||||
var base: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
|
||||||
kSecAttrLabel as String: "bitchat-\(key)"
|
|
||||||
]
|
|
||||||
#if os(macOS)
|
|
||||||
base[kSecAttrSynchronizable as String] = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func attempt(addAccessGroup: Bool) -> OSStatus {
|
|
||||||
var query = base
|
|
||||||
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
|
|
||||||
return SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var status = attempt(addAccessGroup: true)
|
|
||||||
if status == -34018 { // Missing entitlement, retry without access group
|
|
||||||
status = attempt(addAccessGroup: false)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
let status = attempt(addAccessGroup: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Classify the result
|
|
||||||
let result = classifySaveStatus(status)
|
|
||||||
|
|
||||||
// Log all outcomes consistently
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
|
|
||||||
case .duplicateItem:
|
|
||||||
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
|
|
||||||
case .accessDenied:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
|
||||||
case .deviceLocked:
|
|
||||||
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
|
|
||||||
case .storageFull:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain storage full for key: \(key)", category: .keychain)
|
|
||||||
case .otherError(let code):
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
|
||||||
context: "Keychain save failed for key: \(key)", category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry transient errors with exponential backoff
|
|
||||||
if result.isRecoverableError && retryCount > 0 {
|
|
||||||
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
|
|
||||||
usleep(delayMs * 1000)
|
|
||||||
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
|
|
||||||
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to retrieve data with detailed result
|
|
||||||
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
let base: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecReturnData as String: true,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
func attempt(withAccessGroup: Bool) -> OSStatus {
|
|
||||||
var q = base
|
|
||||||
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
|
|
||||||
return SecItemCopyMatching(q as CFDictionary, &result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var status = attempt(withAccessGroup: true)
|
|
||||||
if status == -34018 { status = attempt(withAccessGroup: false) }
|
|
||||||
#else
|
|
||||||
let status = attempt(withAccessGroup: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Classify the result
|
|
||||||
let readResult = classifyReadStatus(status, data: result as? Data)
|
|
||||||
|
|
||||||
// Log all outcomes consistently
|
|
||||||
switch readResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case - no logging needed for missing keys
|
|
||||||
break
|
|
||||||
case .accessDenied:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
|
||||||
case .deviceLocked:
|
|
||||||
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
|
|
||||||
case .authenticationFailed:
|
|
||||||
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
|
|
||||||
case .otherError(let code):
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
|
||||||
context: "Keychain read failed for key: \(key)", category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return readResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify keychain read status into meaningful categories
|
|
||||||
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess:
|
|
||||||
if let data = data {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .otherError(status)
|
|
||||||
case errSecItemNotFound:
|
|
||||||
return .itemNotFound
|
|
||||||
case errSecInteractionNotAllowed:
|
|
||||||
// Device is locked or in a state that doesn't allow keychain access
|
|
||||||
return .deviceLocked
|
|
||||||
case errSecAuthFailed:
|
|
||||||
return .authenticationFailed
|
|
||||||
case -34018: // errSecMissingEntitlement
|
|
||||||
return .accessDenied
|
|
||||||
case errSecNotAvailable:
|
|
||||||
return .accessDenied
|
|
||||||
default:
|
|
||||||
return .otherError(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify keychain save status into meaningful categories
|
|
||||||
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess:
|
|
||||||
return .success
|
|
||||||
case errSecDuplicateItem:
|
|
||||||
return .duplicateItem
|
|
||||||
case errSecInteractionNotAllowed:
|
|
||||||
return .deviceLocked
|
|
||||||
case -34018: // errSecMissingEntitlement
|
|
||||||
return .accessDenied
|
|
||||||
case errSecNotAvailable:
|
|
||||||
return .accessDenied
|
|
||||||
case errSecDiskFull:
|
|
||||||
return .storageFull
|
|
||||||
default:
|
|
||||||
return .otherError(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic Operations
|
// MARK: - Generic Operations
|
||||||
|
|
||||||
private func save(_ value: String, forKey key: String) -> Bool {
|
private func save(_ value: String, forKey key: String) -> Bool {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
let suffix = String(pubkey.suffix(4))
|
let suffix = String(pubkey.suffix(4))
|
||||||
if let nick = nickname?.trimmedOrNilIfEmpty {
|
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
return "\(nick)#\(suffix)"
|
return "\(nick)#\(suffix)"
|
||||||
}
|
}
|
||||||
return "anon#\(suffix)"
|
return "anon#\(suffix)"
|
||||||
@@ -199,7 +199,8 @@ 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) {
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
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,79 +5,6 @@ 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
|
||||||
@@ -99,8 +26,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
// MARK: - Private Properties (CoreLocation)
|
// MARK: - Private Properties (CoreLocation)
|
||||||
|
|
||||||
private let cl: LocationStateManaging
|
private let cl = CLLocationManager()
|
||||||
private let geocoder: LocationStateGeocoding
|
private let geocoder = CLGeocoder()
|
||||||
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
|
||||||
@@ -146,8 +73,6 @@ 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
|
||||||
@@ -167,30 +92,10 @@ 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),
|
||||||
@@ -227,7 +132,12 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func initializePermissionState() {
|
private func initializePermissionState() {
|
||||||
let status = cl.authorizationStatus
|
let status: CLAuthorizationStatus
|
||||||
|
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
|
||||||
@@ -246,7 +156,12 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
// MARK: - Public API (Permissions & Location)
|
// MARK: - Public API (Permissions & Location)
|
||||||
|
|
||||||
func enableLocationChannels() {
|
func enableLocationChannels() {
|
||||||
let status = cl.authorizationStatus
|
let status: CLAuthorizationStatus
|
||||||
|
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()
|
||||||
@@ -597,7 +512,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
|
||||||
.trimmed
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.lowercased()
|
.lowercased()
|
||||||
.replacingOccurrences(of: "#", with: "")
|
.replacingOccurrences(of: "#", with: "")
|
||||||
.filter { allowed.contains($0) }
|
.filter { allowed.contains($0) }
|
||||||
|
|||||||
@@ -6,114 +6,104 @@ final class MeshTopologyTracker {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
||||||
private let hopSize = 8
|
private let hopSize = 8
|
||||||
// Directed claims: Key claims to see Value (neighbors)
|
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
|
||||||
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
|
||||||
// Last time we received an update from a node
|
|
||||||
private var lastSeen: [RoutingID: Date] = [:]
|
|
||||||
|
|
||||||
// Maximum age for topology claims to be considered fresh for routing
|
|
||||||
// Routes computed using stale topology can fail when the network has changed
|
|
||||||
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
|
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims.removeAll()
|
self.adjacency.removeAll()
|
||||||
self.lastSeen.removeAll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the topology with a node's self-reported neighbor list
|
func recordDirectLink(between a: Data?, and b: Data?) {
|
||||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
|
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||||
guard let source = sanitize(sourceData) else { return }
|
|
||||||
// Sanitize neighbors and exclude self-loops
|
|
||||||
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims[source] = validNeighbors
|
var setA = self.adjacency[left] ?? []
|
||||||
self.lastSeen[source] = Date()
|
setA.insert(right)
|
||||||
|
self.adjacency[left] = setA
|
||||||
|
|
||||||
|
var setB = self.adjacency[right] ?? []
|
||||||
|
setB.insert(left)
|
||||||
|
self.adjacency[right] = setB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDirectLink(between a: Data?, and b: Data?) {
|
||||||
|
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
if var setA = self.adjacency[left] {
|
||||||
|
setA.remove(right)
|
||||||
|
self.adjacency[left] = setA.isEmpty ? nil : setA
|
||||||
|
}
|
||||||
|
if var setB = self.adjacency[right] {
|
||||||
|
setB.remove(left)
|
||||||
|
self.adjacency[right] = setB.isEmpty ? nil : setB
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePeer(_ data: Data?) {
|
func removePeer(_ data: Data?) {
|
||||||
guard let peer = sanitize(data) else { return }
|
guard let peer = sanitize(data) else { return }
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims.removeValue(forKey: peer)
|
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
for neighbor in neighbors {
|
||||||
}
|
if var set = self.adjacency[neighbor] {
|
||||||
}
|
set.remove(peer)
|
||||||
|
self.adjacency[neighbor] = set.isEmpty ? nil : set
|
||||||
/// Prune nodes that haven't updated their topology in `age` seconds
|
}
|
||||||
func prune(olderThan age: TimeInterval) {
|
|
||||||
let deadline = Date().addingTimeInterval(-age)
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
let stale = self.lastSeen.filter { $0.value < deadline }
|
|
||||||
for (peer, _) in stale {
|
|
||||||
self.claims.removeValue(forKey: peer)
|
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
|
func recordRoute(_ hops: [Data]) {
|
||||||
|
let sanitized = hops.compactMap { sanitize($0) }
|
||||||
|
guard sanitized.count >= 2 else { return }
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
for idx in 0..<(sanitized.count - 1) {
|
||||||
|
let left = sanitized[idx]
|
||||||
|
let right = sanitized[idx + 1]
|
||||||
|
guard left != right else { continue }
|
||||||
|
|
||||||
|
var setA = self.adjacency[left] ?? []
|
||||||
|
setA.insert(right)
|
||||||
|
self.adjacency[left] = setA
|
||||||
|
|
||||||
|
var setB = self.adjacency[right] ?? []
|
||||||
|
setB.insert(left)
|
||||||
|
self.adjacency[right] = setB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
|
||||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||||
if source == target { return [] } // Direct connection, no intermediate hops
|
if source == target { return [source] }
|
||||||
|
|
||||||
return queue.sync {
|
let graph = queue.sync { adjacency }
|
||||||
let now = Date()
|
guard graph[source] != nil, graph[target] != nil else { return nil }
|
||||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
|
||||||
|
|
||||||
// BFS
|
var visited: Set<RoutingID> = [source]
|
||||||
var visited: Set<RoutingID> = [source]
|
var queuePaths: [[RoutingID]] = [[source]]
|
||||||
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
|
var index = 0
|
||||||
var queuePaths: [[RoutingID]] = [[source]]
|
|
||||||
|
|
||||||
while !queuePaths.isEmpty {
|
while index < queuePaths.count {
|
||||||
let path = queuePaths.removeFirst()
|
let path = queuePaths[index]
|
||||||
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
|
index += 1
|
||||||
// If maxHops = 10, max edges = 11, max nodes = 12.
|
guard path.count <= maxHops else { continue }
|
||||||
if path.count > maxHops + 1 { continue }
|
guard let last = path.last, let neighbors = graph[last] else { continue }
|
||||||
|
|
||||||
guard let last = path.last else { continue }
|
for neighbor in neighbors {
|
||||||
|
if visited.contains(neighbor) { continue }
|
||||||
// Get neighbors that 'last' claims to see
|
var nextPath = path
|
||||||
guard let neighbors = claims[last] else { continue }
|
nextPath.append(neighbor)
|
||||||
|
if neighbor == target { return nextPath }
|
||||||
// Check if 'last' node's topology info is fresh
|
if nextPath.count <= maxHops {
|
||||||
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
|
|
||||||
continue // Skip stale nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
for neighbor in neighbors {
|
|
||||||
if visited.contains(neighbor) { continue }
|
|
||||||
|
|
||||||
// CONFIRMED EDGE CHECK:
|
|
||||||
// 'last' claims 'neighbor' (checked above)
|
|
||||||
// Does 'neighbor' claim 'last'?
|
|
||||||
guard let neighborClaims = claims[neighbor],
|
|
||||||
neighborClaims.contains(last) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if 'neighbor' node's topology info is fresh
|
|
||||||
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
|
|
||||||
continue // Skip edges to stale nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextPath = path
|
|
||||||
nextPath.append(neighbor)
|
|
||||||
|
|
||||||
if neighbor == target {
|
|
||||||
// Return only intermediate hops
|
|
||||||
// Path: [Source, I1, I2, Target] -> [I1, I2]
|
|
||||||
return Array(nextPath.dropFirst().dropLast())
|
|
||||||
}
|
|
||||||
|
|
||||||
visited.insert(neighbor)
|
|
||||||
queuePaths.append(nextPath)
|
queuePaths.append(nextPath)
|
||||||
}
|
}
|
||||||
|
visited.insert(neighbor)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ enum ContentNormalizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trim and collapse whitespace
|
// Trim and collapse whitespace
|
||||||
let trimmed = simplified.trimmed
|
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
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,7 +6,6 @@
|
|||||||
// 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,25 +1,11 @@
|
|||||||
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.)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
|
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
// Outbox entry with timestamp for TTL-based eviction
|
|
||||||
private struct QueuedMessage {
|
|
||||||
let content: String
|
|
||||||
let nickname: String
|
|
||||||
let messageID: String
|
|
||||||
let timestamp: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
|
||||||
|
|
||||||
// Outbox limits to prevent unbounded memory growth
|
|
||||||
private static let maxMessagesPerPeer = 100
|
|
||||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
|
||||||
|
|
||||||
init(transports: [Transport]) {
|
init(transports: [Transport]) {
|
||||||
self.transports = transports
|
self.transports = transports
|
||||||
@@ -48,60 +34,52 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Selection
|
|
||||||
|
|
||||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
|
||||||
transports.first { $0.isPeerReachable(peerID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectedTransport(for peerID: PeerID) -> Transport? {
|
|
||||||
transports.first { $0.isPeerConnected(peerID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Sending
|
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
// Try to find a reachable transport
|
||||||
|
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later with timestamp for TTL tracking
|
// Queue for later
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))…", category: .session)
|
||||||
outbox[peerID]?.append(message)
|
|
||||||
|
|
||||||
// Enforce per-peer size limit with FIFO eviction
|
|
||||||
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
|
|
||||||
let evicted = outbox[peerID]?.removeFirst()
|
|
||||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
transport.sendReadReceipt(receipt, to: peerID)
|
transport.sendReadReceipt(receipt, to: peerID)
|
||||||
} else if !transports.isEmpty {
|
} else if !transports.isEmpty {
|
||||||
|
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
|
||||||
|
// Or better: just try the first one that supports it?
|
||||||
|
// Existing logic preferred mesh, then nostr.
|
||||||
|
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
|
||||||
|
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
|
||||||
|
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
|
||||||
|
// But let's stick to the reachable check.
|
||||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
transport.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
if let transport = connectedTransport(for: peerID) {
|
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
||||||
} else if let transport = reachableTransport(for: peerID) {
|
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
|
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
} else {
|
||||||
|
// Fallback: try all? or just the last one?
|
||||||
|
// Old logic: if mesh connected, mesh. Else nostr.
|
||||||
|
// Note: NostrTransport.isPeerReachable now returns true if mapped.
|
||||||
|
// If not mapped, we can't send via Nostr anyway.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,25 +88,17 @@ final class MessageRouter {
|
|||||||
func flushOutbox(for peerID: PeerID) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
let now = Date()
|
|
||||||
var remaining: [QueuedMessage] = []
|
for (content, nickname, messageID) in queued {
|
||||||
|
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
|
||||||
for message in queued {
|
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Skip expired messages (TTL exceeded)
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
|
||||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if let transport = reachableTransport(for: peerID) {
|
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
|
||||||
} else {
|
} else {
|
||||||
remaining.append(message)
|
remaining.append((content, nickname, messageID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining.isEmpty {
|
if remaining.isEmpty {
|
||||||
outbox.removeValue(forKey: peerID)
|
outbox.removeValue(forKey: peerID)
|
||||||
} else {
|
} else {
|
||||||
@@ -139,15 +109,4 @@ final class MessageRouter {
|
|||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Periodically clean up expired messages from all outboxes
|
|
||||||
func cleanupExpiredMessages() {
|
|
||||||
let now = Date()
|
|
||||||
for peerID in Array(outbox.keys) {
|
|
||||||
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
|
|
||||||
if outbox[peerID]?.isEmpty == true {
|
|
||||||
outbox.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,6 @@ 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.
|
||||||
@@ -38,55 +17,14 @@ 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 = storage.object(forKey: torPreferenceKey) as? Bool {
|
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||||
userTorEnabled = stored
|
userTorEnabled = stored
|
||||||
} else {
|
} else {
|
||||||
userTorEnabled = true
|
userTorEnabled = true
|
||||||
@@ -96,16 +34,16 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
let allowed = basePolicyAllowed()
|
let allowed = basePolicyAllowed()
|
||||||
activationAllowed = allowed
|
activationAllowed = allowed
|
||||||
torAutoStartDesired = allowed && userTorEnabled
|
torAutoStartDesired = allowed && userTorEnabled
|
||||||
torController.setAutoStartAllowed(torAutoStartDesired)
|
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||||
applyTorState(torDesired: torAutoStartDesired)
|
applyTorState(torDesired: torAutoStartDesired)
|
||||||
if allowed {
|
if allowed {
|
||||||
relayController.connect()
|
NostrRelayManager.shared.connect()
|
||||||
} else {
|
} else {
|
||||||
relayController.disconnect()
|
NostrRelayManager.shared.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// React to location permission changes
|
// React to location permission changes
|
||||||
locationPermissionPublisher
|
LocationChannelManager.shared.$permissionState
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.reevaluate()
|
self?.reevaluate()
|
||||||
@@ -113,7 +51,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// React to mutual favorites changes
|
// React to mutual favorites changes
|
||||||
mutualFavoritesPublisher
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.reevaluate()
|
self?.reevaluate()
|
||||||
@@ -124,8 +62,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
|
||||||
storage.set(enabled, forKey: torPreferenceKey)
|
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||||
notificationCenter.post(
|
NotificationCenter.default.post(
|
||||||
name: .TorUserPreferenceChanged,
|
name: .TorUserPreferenceChanged,
|
||||||
object: nil,
|
object: nil,
|
||||||
userInfo: ["enabled": enabled]
|
userInfo: ["enabled": enabled]
|
||||||
@@ -144,33 +82,33 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
if statusChanged || torChanged {
|
if statusChanged || torChanged {
|
||||||
torAutoStartDesired = torDesired
|
torAutoStartDesired = torDesired
|
||||||
torController.setAutoStartAllowed(torDesired)
|
TorManager.shared.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)
|
||||||
relayController.disconnect()
|
NostrRelayManager.shared.disconnect()
|
||||||
}
|
}
|
||||||
relayController.connect()
|
NostrRelayManager.shared.connect()
|
||||||
} else if statusChanged {
|
} else if statusChanged {
|
||||||
relayController.disconnect()
|
NostrRelayManager.shared.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func basePolicyAllowed() -> Bool {
|
private func basePolicyAllowed() -> Bool {
|
||||||
let permOK = permissionProvider() == .authorized
|
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||||
let hasMutual = !mutualFavoritesProvider().isEmpty
|
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
return permOK || hasMutual
|
return permOK || hasMutual
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTorState(torDesired: Bool) {
|
private func applyTorState(torDesired: Bool) {
|
||||||
proxyController.setProxyMode(useTor: torDesired)
|
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||||
if torDesired {
|
if torDesired {
|
||||||
torController.startIfNeeded()
|
TorManager.shared.startIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
torController.shutdownCompletely()
|
TorManager.shared.shutdownCompletely()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Noise
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -201,153 +199,64 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
|
||||||
// BCH-01-009: Load or create static identity key with proper error handling
|
// Load or create static identity key (ONLY from keychain)
|
||||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain with proper error classification
|
// Try to load from keychain
|
||||||
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
|
||||||
|
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||||
switch noiseKeyResult {
|
loadedKey = key
|
||||||
case .success(let identityData):
|
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
||||||
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
|
||||||
loadedKey = key
|
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
|
||||||
} else {
|
|
||||||
// Data corrupted, regenerate
|
|
||||||
SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain)
|
|
||||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case: no key exists yet, create new one
|
|
||||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
|
||||||
|
|
||||||
case .accessDenied:
|
|
||||||
// Critical error - log but proceed with ephemeral key (will be lost on restart)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Keychain access denied - using ephemeral identity", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
|
||||||
// Recoverable error - use ephemeral key and warn
|
|
||||||
SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
|
|
||||||
case .otherError(let status):
|
|
||||||
// Unexpected error - log and use ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Unexpected keychain error - using ephemeral identity", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
// If no identity exists, create new one
|
||||||
|
else {
|
||||||
|
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
let keyData = loadedKey.rawRepresentation
|
||||||
|
|
||||||
|
// Save to keychain
|
||||||
|
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
||||||
|
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
|
||||||
|
}
|
||||||
|
|
||||||
// Now assign the final value
|
// Now assign the final value
|
||||||
self.staticIdentityKey = loadedKey
|
self.staticIdentityKey = loadedKey
|
||||||
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
||||||
|
|
||||||
// BCH-01-009: Load or create signing key pair with proper error handling
|
// Load or create signing key pair
|
||||||
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
||||||
|
|
||||||
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey")
|
// Try to load from keychain
|
||||||
|
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
|
||||||
switch signingKeyResult {
|
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||||
case .success(let signingData):
|
loadedSigningKey = key
|
||||||
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
||||||
loadedSigningKey = key
|
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
|
||||||
} else {
|
|
||||||
// Data corrupted, regenerate
|
|
||||||
SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain)
|
|
||||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case: no key exists yet, create new one
|
|
||||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
|
||||||
|
|
||||||
case .accessDenied:
|
|
||||||
// Critical error - log but proceed with ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Keychain access denied - using ephemeral signing key", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
|
||||||
// Recoverable error - use ephemeral key and warn
|
|
||||||
SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
|
|
||||||
case .otherError(let status):
|
|
||||||
// Unexpected error - log and use ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Unexpected keychain error - using ephemeral signing key", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
// If no signing key exists, create new one
|
||||||
|
else {
|
||||||
|
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
||||||
|
let keyData = loadedSigningKey.rawRepresentation
|
||||||
|
|
||||||
|
// Save to keychain
|
||||||
|
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
||||||
|
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
|
||||||
|
}
|
||||||
|
|
||||||
// Now assign the signing keys
|
// Now assign the signing keys
|
||||||
self.signingKey = loadedSigningKey
|
self.signingKey = loadedSigningKey
|
||||||
self.signingPublicKey = signingKey.publicKey
|
self.signingPublicKey = signingKey.publicKey
|
||||||
|
|
||||||
// Initialize session manager
|
// Initialize session manager
|
||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
startRekeyTimer()
|
startRekeyTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BCH-01-009: Key Generation Helpers with Save Verification
|
|
||||||
|
|
||||||
/// Generate and save a new Noise static key, verifying the save succeeds
|
|
||||||
private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {
|
|
||||||
let newKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
let keyData = newKey.rawRepresentation
|
|
||||||
|
|
||||||
// Save to keychain and verify success
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true)
|
|
||||||
case .duplicateItem:
|
|
||||||
// This shouldn't happen since we just tried to load, but handle it
|
|
||||||
SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Save failed - log but continue with the key (it will be ephemeral)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Failed to persist noise static key - identity will be lost on restart",
|
|
||||||
category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newKey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate and save a new Ed25519 signing key, verifying the save succeeds
|
|
||||||
private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {
|
|
||||||
let newKey = Curve25519.Signing.PrivateKey()
|
|
||||||
let keyData = newKey.rawRepresentation
|
|
||||||
|
|
||||||
// Save to keychain and verify success
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true)
|
|
||||||
case .duplicateItem:
|
|
||||||
// This shouldn't happen since we just tried to load, but handle it
|
|
||||||
SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Save failed - log but continue with the key (it will be ephemeral)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Failed to persist signing key - identity will be lost on restart",
|
|
||||||
category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
@@ -618,17 +527,6 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
rateLimiter.resetAll()
|
rateLimiter.resetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
|
|
||||||
func clearSession(for peerID: PeerID) {
|
|
||||||
sessionManager.removeSession(for: peerID)
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
|
||||||
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
|
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,9 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
// Minimal Nostr transport conforming to Transport for offline sending
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport, @unchecked Sendable {
|
final class NostrTransport: Transport, @unchecked Sendable {
|
||||||
struct Dependencies {
|
|
||||||
let notificationCenter: NotificationCenter
|
|
||||||
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
|
|
||||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
|
||||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
|
||||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
|
||||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
|
||||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
|
||||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
|
||||||
|
|
||||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
|
||||||
Dependencies(
|
|
||||||
notificationCenter: .default,
|
|
||||||
loadFavorites: { FavoritesPersistenceService.shared.favorites },
|
|
||||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
|
||||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
|
||||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
|
||||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
|
||||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
|
||||||
scheduleAfter: { delay, action in
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provide BLE short peer ID for BitChat embedding
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID = PeerID(str: "")
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
@@ -44,27 +17,20 @@ 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(
|
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||||
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 = self.dependencies.loadFavorites()
|
let favorites = FavoritesPersistenceService.shared.favorites
|
||||||
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) }
|
||||||
@@ -74,14 +40,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
if let favoriteStatusObserver {
|
|
||||||
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: .favoriteStatusChanged,
|
forName: .favoriteStatusChanged,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: nil
|
queue: nil
|
||||||
@@ -92,7 +52,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
private func refreshReachablePeers() {
|
private func refreshReachablePeers() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let favorites = dependencies.loadFavorites()
|
let favorites = FavoritesPersistenceService.shared.favorites
|
||||||
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) }
|
||||||
@@ -158,15 +118,32 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
// Convert recipient npub -> hex (x-only)
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else {
|
||||||
|
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,31 +158,58 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard 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)
|
||||||
|
// Convert recipient npub -> hex
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
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 {
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
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)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,17 +221,21 @@ extension NostrTransport {
|
|||||||
// MARK: Geohash ACK helpers
|
// MARK: Geohash ACK helpers
|
||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,12 +243,19 @@ extension NostrTransport {
|
|||||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard !recipientHex.isEmpty else { return }
|
guard !recipientHex.isEmpty else { return }
|
||||||
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,32 +263,6 @@ extension NostrTransport {
|
|||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
extension NostrTransport {
|
extension NostrTransport {
|
||||||
/// Converts npub bech32 string to hex pubkey
|
|
||||||
@MainActor
|
|
||||||
private func npubToHex(_ npub: String) -> String? {
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(npub)
|
|
||||||
guard hrp == "npub" else { return nil }
|
|
||||||
return data.hexEncodedString()
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates and sends a gift-wrapped private message event
|
|
||||||
@MainActor
|
|
||||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if registerPending {
|
|
||||||
dependencies.registerPendingGiftWrap(event.id)
|
|
||||||
}
|
|
||||||
dependencies.sendEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Must be called within a barrier on `queue`
|
/// Must be called within a barrier on `queue`
|
||||||
private func processReadQueueIfNeeded() {
|
private func processReadQueueIfNeeded() {
|
||||||
guard !isSendingReadAcks else { return }
|
guard !isSendingReadAcks else { return }
|
||||||
@@ -286,21 +275,36 @@ extension NostrTransport {
|
|||||||
/// Sends a single read ack item (called after extraction from queue within barrier)
|
/// Sends a single read ack item (called after extraction from queue within barrier)
|
||||||
private func sendReadAckItem(_ item: QueuedRead) {
|
private func sendReadAckItem(_ item: QueuedRead) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { scheduleNextReadAck() }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
// Convert recipient npub -> hex
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
let recipientHex: String
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
do {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
|
||||||
|
scheduleNextReadAck()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
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)
|
||||||
|
scheduleNextReadAck(); return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
|
||||||
|
scheduleNextReadAck(); return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
scheduleNextReadAck()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNextReadAck() {
|
private func scheduleNextReadAck() {
|
||||||
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 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()
|
||||||
@@ -311,12 +315,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 = dependencies.favoriteStatusForNoiseKey(noiseKey),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: 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 = dependencies.favoriteStatusForPeerID(peerID),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// 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)
|
||||||
@@ -15,103 +14,24 @@ 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 {
|
||||||
isRunningTestsProvider()
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
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 }
|
||||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
} else {
|
} else {
|
||||||
@@ -144,7 +64,7 @@ final class NotificationService {
|
|||||||
trigger: nil // Deliver immediately
|
trigger: nil // Deliver immediately
|
||||||
)
|
)
|
||||||
|
|
||||||
requestDeliverer.add(request)
|
UNUserNotificationCenter.current().add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMentionNotification(from sender: String, message: String) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -176,8 +96,7 @@ 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"
|
||||||
// Fixed identifier so iOS updates the existing notification instead of creating new ones
|
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||||
let identifier = "network-available"
|
|
||||||
|
|
||||||
sendLocalNotification(
|
sendLocalNotification(
|
||||||
title: title,
|
title: title,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct NotificationStreamAssembler {
|
struct NotificationStreamAssembler {
|
||||||
@@ -63,7 +62,6 @@ struct NotificationStreamAssembler {
|
|||||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||||
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
|
|
||||||
|
|
||||||
let lengthOffset = 12
|
let lengthOffset = 12
|
||||||
let payloadLength: Int
|
let payloadLength: Int
|
||||||
@@ -82,15 +80,6 @@ struct NotificationStreamAssembler {
|
|||||||
var frameLength = framePrefix + payloadLength
|
var frameLength = framePrefix + payloadLength
|
||||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
|
||||||
if hasRoute {
|
|
||||||
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
|
|
||||||
let routeCountIndex = buffer.startIndex + routeCountOffset
|
|
||||||
guard buffer.count > routeCountOffset else { break }
|
|
||||||
let routeCount = Int(buffer[routeCountIndex])
|
|
||||||
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||||
if payloadLength < rawLengthFieldBytes {
|
if payloadLength < rawLengthFieldBytes {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
@@ -59,10 +58,6 @@ protocol Transport: AnyObject {
|
|||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Pending file management (BCH-01-002: files held in memory until user accepts)
|
|
||||||
func acceptPendingFile(id: String) -> URL?
|
|
||||||
func declinePendingFile(id: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
@@ -75,9 +70,6 @@ extension Transport {
|
|||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
sendMessage(content, mentions: mentions)
|
sendMessage(content, mentions: mentions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func acceptPendingFile(id: String) -> URL? { nil }
|
|
||||||
func declinePendingFile(id: String) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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
|
||||||
@@ -97,12 +96,11 @@ enum TransportConfig {
|
|||||||
// Keep scanning fully ON when we saw traffic very recently
|
// Keep scanning fully ON when we saw traffic very recently
|
||||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||||
static let bleExpectedWritePerFragmentMs: Int = 20
|
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||||
static let bleExpectedWriteMaxMs: Int = 5000
|
static let bleExpectedWriteMaxMs: Int = 2000
|
||||||
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
|
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||||
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
|
static let bleFragmentSpacingMs: Int = 5
|
||||||
static let bleFragmentSpacingMs: Int = 30
|
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||||
static let bleFragmentSpacingDirectedMs: Int = 25
|
|
||||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||||
@@ -133,6 +131,9 @@ enum TransportConfig {
|
|||||||
static let nostrShortKeyDisplayLength: Int = 8
|
static let nostrShortKeyDisplayLength: Int = 8
|
||||||
static let nostrConvKeyPrefixLength: Int = 16
|
static let nostrConvKeyPrefixLength: Int = 16
|
||||||
|
|
||||||
|
// Compression
|
||||||
|
static let compressionThresholdBytes: Int = 100
|
||||||
|
|
||||||
// Message deduplication
|
// Message deduplication
|
||||||
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
||||||
static let messageDedupMaxCount: Int = 1000
|
static let messageDedupMaxCount: Int = 1000
|
||||||
@@ -161,14 +162,6 @@ enum TransportConfig {
|
|||||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||||
|
|
||||||
// BCH-01-004: Rate-limiting for subscription-triggered announces
|
|
||||||
// Prevents rapid enumeration attacks by rate-limiting announce responses
|
|
||||||
static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central
|
|
||||||
static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier
|
|
||||||
static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period
|
|
||||||
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
|
||||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
|
||||||
|
|
||||||
// Store-and-forward for directed packets at relays
|
// Store-and-forward for directed packets at relays
|
||||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||||
|
|
||||||
@@ -210,18 +203,4 @@ enum TransportConfig {
|
|||||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
// Gossip Sync Configuration
|
|
||||||
static let syncSeenCapacity: Int = 1000
|
|
||||||
static let syncGCSMaxBytes: Int = 400
|
|
||||||
static let syncGCSTargetFpr: Double = 0.01
|
|
||||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
|
||||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
|
||||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
|
||||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
|
||||||
static let syncFragmentCapacity: Int = 600
|
|
||||||
static let syncFileTransferCapacity: Int = 200
|
|
||||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
|
||||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
|
||||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
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,7 +6,6 @@ final class GossipSyncManager {
|
|||||||
func sendPacket(_ packet: BitchatPacket)
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
func getConnectedPeers() -> [PeerID]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PacketStore {
|
private struct PacketStore {
|
||||||
@@ -77,7 +74,6 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
private let requestSyncManager: RequestSyncManager
|
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
// Storage: broadcast packets by type, and latest announce per sender
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
@@ -92,10 +88,9 @@ final class GossipSyncManager {
|
|||||||
private var lastStalePeerCleanup: Date = .distantPast
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
private var syncSchedules: [SyncSchedule] = []
|
private var syncSchedules: [SyncSchedule] = []
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
self.requestSyncManager = requestSyncManager
|
|
||||||
var schedules: [SyncSchedule] = []
|
var schedules: [SyncSchedule] = []
|
||||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
@@ -207,19 +202,6 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
|
||||||
// Unicast sync to connected peers to allow RSR attribution
|
|
||||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
|
||||||
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
|
|
||||||
for peerID in connectedPeers {
|
|
||||||
sendRequestSync(to: peerID, types: types)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to broadcast (discovery phase)
|
|
||||||
sendRequestSync(for: types)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload(for: types)
|
let payload = buildGcsPayload(for: types)
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
@@ -236,9 +218,6 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||||
// Register the request for RSR validation
|
|
||||||
requestSyncManager.registerRequest(to: peerID)
|
|
||||||
|
|
||||||
let payload = buildGcsPayload(for: types)
|
let payload = buildGcsPayload(for: types)
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID.id
|
var temp = peerID.id
|
||||||
@@ -283,7 +262,6 @@ final class GossipSyncManager {
|
|||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +274,6 @@ final class GossipSyncManager {
|
|||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,7 +286,6 @@ final class GossipSyncManager {
|
|||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,7 +298,6 @@ final class GossipSyncManager {
|
|||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,13 +366,11 @@ final class GossipSyncManager {
|
|||||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
cleanupExpiredMessages()
|
cleanupExpiredMessages()
|
||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
|
||||||
|
|
||||||
for index in syncSchedules.indices {
|
for index in syncSchedules.indices {
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
syncSchedules[index].lastSent = now
|
syncSchedules[index].lastSent = now
|
||||||
sendPeriodicSync(for: syncSchedules[index].types)
|
sendRequestSync(for: syncSchedules[index].types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import struct BitFoundation.BitchatPacket
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
//
|
|
||||||
// RequestSyncManager.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
/// Manages outgoing sync requests and validates incoming responses.
|
|
||||||
///
|
|
||||||
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
|
|
||||||
/// that we have actively requested sync from.
|
|
||||||
final class RequestSyncManager {
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
|
||||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
|
||||||
private let responseWindow: TimeInterval
|
|
||||||
private let now: () -> TimeInterval
|
|
||||||
|
|
||||||
init(
|
|
||||||
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.
|
|
||||||
/// - Parameter peerID: The peer we are requesting sync from
|
|
||||||
func registerRequest(to peerID: PeerID) {
|
|
||||||
let now = self.now()
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
|
||||||
self.pendingRequests[peerID] = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a packet from a peer is a valid response to a sync request.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - peerID: The sender of the packet
|
|
||||||
/// - isRSR: Whether the packet is marked as a Request-Sync Response
|
|
||||||
/// - Returns: true if we have a pending request for this peer and the window is open
|
|
||||||
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
|
|
||||||
guard isRSR else { return false }
|
|
||||||
|
|
||||||
return queue.sync {
|
|
||||||
guard let requestTime = pendingRequests[peerID] else {
|
|
||||||
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = self.now()
|
|
||||||
if now - requestTime > responseWindow {
|
|
||||||
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
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Periodic cleanup of expired requests
|
|
||||||
func cleanup() {
|
|
||||||
let now = self.now()
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
let originalCount = self.pendingRequests.count
|
|
||||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
|
||||||
now - timestamp <= self.responseWindow
|
|
||||||
}
|
|
||||||
let removed = originalCount - self.pendingRequests.count
|
|
||||||
if removed > 0 {
|
|
||||||
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var debugPendingRequestCount: Int {
|
|
||||||
queue.sync { pendingRequests.count }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
||||||
|
|||||||
+3
-3
@@ -6,12 +6,12 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
private import Compression
|
import Compression
|
||||||
|
|
||||||
struct CompressionUtil {
|
struct CompressionUtil {
|
||||||
// Compression threshold - don't compress if data is smaller than this
|
// Compression threshold - don't compress if data is smaller than this
|
||||||
static let compressionThreshold = Constants.compressionThresholdBytes // bytes
|
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
|
||||||
|
|
||||||
// Compress data using zlib algorithm (most compatible)
|
// Compress data using zlib algorithm (most compatible)
|
||||||
static func compress(_ data: Data) -> Data? {
|
static func compress(_ data: Data) -> Data? {
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Data+SHA256.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Created by Islam on 26/09/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import struct Foundation.Data
|
||||||
|
import struct CryptoKit.SHA256
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
/// Returns the hex representation of SHA256 hash
|
||||||
|
func sha256Fingerprint() -> String {
|
||||||
|
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||||
|
sha256Hash().hexEncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the SHA256 hash wrapped in Data
|
||||||
|
func sha256Hash() -> Data {
|
||||||
|
Data(SHA256.hash(data: self))
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-6
@@ -1,13 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
||||||
public enum FileTransferLimits {
|
enum FileTransferLimits {
|
||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Voice notes stay small for low-latency relays.
|
/// Voice notes stay small for low-latency relays.
|
||||||
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
public static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
public static let maxFramedFileBytes: Int = {
|
static let maxFramedFileBytes: Int = {
|
||||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
||||||
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
||||||
@@ -17,7 +19,7 @@ public enum FileTransferLimits {
|
|||||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static func isValidPayload(_ size: Int) -> Bool {
|
static func isValidPayload(_ size: Int) -> Bool {
|
||||||
size <= maxPayloadBytes
|
size <= maxPayloadBytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,9 @@ 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? {
|
||||||
guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil }
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
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.
|
||||||
@@ -50,13 +52,11 @@ struct InputValidator {
|
|||||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||||
|
|
||||||
/// Validates timestamp is reasonable (not too far in past or future)
|
/// Validates timestamp is reasonable (not too far in past or future)
|
||||||
/// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window
|
|
||||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
// 5 minutes = 300 seconds (industry standard for replay protection)
|
let oneHourAgo = now.addingTimeInterval(-3600)
|
||||||
let fiveMinutesAgo = now.addingTimeInterval(-300)
|
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||||
let fiveMinutesFromNow = now.addingTimeInterval(300)
|
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||||
return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,7 +78,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
@@ -145,11 +144,10 @@ 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; whitespace-only becomes ""
|
// Trim whitespace whenever nickname is set
|
||||||
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
|
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
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 {
|
||||||
@@ -281,6 +279,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||||
// Show Tor status once per app launch
|
// Show Tor status once per app launch
|
||||||
var torStatusAnnounced = false
|
var torStatusAnnounced = false
|
||||||
|
private var torProgressCancellable: AnyCancellable?
|
||||||
|
private var lastTorProgressAnnounced = -1
|
||||||
// Track whether a Tor restart is pending so we only announce
|
// Track whether a Tor restart is pending so we only announce
|
||||||
// "tor restarted" after an actual restart, not the first launch.
|
// "tor restarted" after an actual restart, not the first launch.
|
||||||
var torRestartPending: Bool = false
|
var torRestartPending: Bool = false
|
||||||
@@ -323,6 +323,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
)
|
)
|
||||||
// Channel activity tracking for background nudges
|
// Channel activity tracking for background nudges
|
||||||
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
|
||||||
|
private var lastPublicActivityNotifyAt: [String: Date] = [:]
|
||||||
|
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
|
||||||
// Geohash participant tracker
|
// Geohash participant tracker
|
||||||
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||||
// Participants who indicated they teleported (by tag in their events)
|
// Participants who indicated they teleported (by tag in their events)
|
||||||
@@ -446,7 +448,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
self.deduplicationService = MessageDeduplicationService()
|
self.deduplicationService = MessageDeduplicationService()
|
||||||
|
|
||||||
// Wire up dependencies
|
// Wire up dependencies
|
||||||
self.commandProcessor.contextProvider = self
|
self.commandProcessor.chatViewModel = self
|
||||||
self.participantTracker.configure(context: self)
|
self.participantTracker.configure(context: self)
|
||||||
|
|
||||||
// Subscribe to privateChatManager changes to trigger UI updates
|
// Subscribe to privateChatManager changes to trigger UI updates
|
||||||
@@ -501,6 +503,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
addGeohashOnlySystemMessage(
|
addGeohashOnlySystemMessage(
|
||||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||||
)
|
)
|
||||||
|
// Suppress incremental Tor progress messages
|
||||||
|
torProgressCancellable = nil
|
||||||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||||
torStatusAnnounced = true
|
torStatusAnnounced = true
|
||||||
addGeohashOnlySystemMessage(
|
addGeohashOnlySystemMessage(
|
||||||
@@ -627,6 +631,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Listen for delivery acknowledgments
|
||||||
|
|
||||||
// When app becomes active, send read receipts for visible messages
|
// When app becomes active, send read receipts for visible messages
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -749,7 +755,8 @@ 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) {
|
||||||
nickname = savedNickname.trimmed
|
// Trim whitespace when loading
|
||||||
|
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
} else {
|
} else {
|
||||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||||
saveNickname()
|
saveNickname()
|
||||||
@@ -765,10 +772,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateAndSaveNickname() {
|
func validateAndSaveNickname() {
|
||||||
nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))"
|
// Trim whitespace from nickname
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -989,7 +1006,8 @@ 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
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
|
||||||
// Check for commands
|
// Check for commands
|
||||||
if content.hasPrefix("/") {
|
if content.hasPrefix("/") {
|
||||||
@@ -1450,6 +1468,56 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
selectedPrivateChatFingerprint = nil
|
selectedPrivateChatFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Nostr Message Handling
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@objc private func handleNostrMessage(_ notification: Notification) {
|
||||||
|
guard let message = notification.userInfo?["message"] as? BitchatMessage else { return }
|
||||||
|
|
||||||
|
// Store the Nostr pubkey if provided (for messages from unknown senders)
|
||||||
|
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
|
||||||
|
let senderPeerID = message.senderPeerID {
|
||||||
|
// Store mapping for read receipts
|
||||||
|
nostrKeyMapping[senderPeerID] = nostrPubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the Nostr message through the same flow as Bluetooth messages
|
||||||
|
didReceiveMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleDeliveryAcknowledgment(_ notification: Notification) {
|
||||||
|
guard let messageId = notification.userInfo?["messageId"] as? String else { return }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Update the delivery status for the message
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||||
|
// Update delivery status to delivered
|
||||||
|
messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||||||
|
|
||||||
|
// Schedule UI update for delivery status
|
||||||
|
// UI will update automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update in private chats if it's a private message
|
||||||
|
for (peerID, chatMessages) in privateChats {
|
||||||
|
if let index = chatMessages.firstIndex(where: { $0.id == messageId }) {
|
||||||
|
privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
|
||||||
|
// UI will update automatically
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleNostrReadReceipt(_ notification: Notification) {
|
||||||
|
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
|
||||||
|
|
||||||
|
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
|
||||||
|
|
||||||
|
// Process the read receipt through the same flow as Bluetooth read receipts
|
||||||
|
didReceiveReadReceipt(receipt)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
|
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
|
||||||
// Update private chat peer if needed when peer status changes
|
// Update private chat peer if needed when peer status changes
|
||||||
@@ -1800,15 +1868,7 @@ 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] = []
|
||||||
@@ -1995,42 +2055,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-013: Clear iOS app switcher snapshots
|
|
||||||
// These are stored in Library/Caches/Snapshots/<bundle_id>/
|
|
||||||
#if os(iOS)
|
|
||||||
Self.clearAppSwitcherSnapshots()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force immediate UI update for panic mode
|
// Force immediate UI update for panic mode
|
||||||
// UI updates immediately - no flushing needed
|
// UI updates immediately - no flushing needed
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
|
||||||
/// iOS stores preview screenshots in Library/Caches/Snapshots/<bundle_id>/
|
|
||||||
/// These could reveal sensitive information visible in the app at the time
|
|
||||||
#if os(iOS)
|
|
||||||
private nonisolated static func clearAppSwitcherSnapshots() {
|
|
||||||
do {
|
|
||||||
let cacheDir = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
|
||||||
let snapshotsDir = cacheDir.appendingPathComponent("Snapshots", isDirectory: true)
|
|
||||||
|
|
||||||
// Clear all snapshots (iOS stores them in subdirectories by bundle ID and scene)
|
|
||||||
if FileManager.default.fileExists(atPath: snapshotsDir.path) {
|
|
||||||
let contents = try FileManager.default.contentsOfDirectory(at: snapshotsDir, includingPropertiesForKeys: nil)
|
|
||||||
for item in contents {
|
|
||||||
try FileManager.default.removeItem(at: item)
|
|
||||||
}
|
|
||||||
SecureLogger.info("🗑️ Cleared app switcher snapshots during panic clear", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to clear app switcher snapshots: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Autocomplete
|
// MARK: - Autocomplete
|
||||||
|
|
||||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
@@ -3000,7 +3031,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.trimmed.isEmpty || message.isPrivate else { return }
|
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }
|
||||||
|
|
||||||
// Route to appropriate handler
|
// Route to appropriate handler
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
@@ -3015,91 +3046,46 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find message index trying both short (16-hex) and long (64-hex) peer ID formats.
|
|
||||||
/// Returns the peer ID where the message was found and its index, or nil if not found.
|
|
||||||
private func findMessageIndex(messageID: String, peerID: PeerID) -> (peerID: PeerID, index: Int)? {
|
|
||||||
// Try direct lookup first
|
|
||||||
if let messages = privateChats[peerID],
|
|
||||||
let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (peerID, idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with full noise key if peerID is short (16 hex chars)
|
|
||||||
if peerID.bare.count == 16,
|
|
||||||
let peer = unifiedPeerService.getPeer(by: peerID),
|
|
||||||
!peer.noisePublicKey.isEmpty {
|
|
||||||
let longID = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
if let messages = privateChats[longID],
|
|
||||||
let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (longID, idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with short form if peerID is long (64 hex = noise key)
|
|
||||||
if peerID.bare.count == 64 {
|
|
||||||
let shortID = peerID.toShort()
|
|
||||||
if let messages = privateChats[shortID],
|
|
||||||
let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (shortID, idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Low-level BLE events
|
// Low-level BLE events
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
switch type {
|
switch type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
|
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
|
||||||
|
|
||||||
// BCH-01-012: Check blocking before processing private message to prevent notification bypass
|
|
||||||
if isPeerBlocked(peerID) {
|
|
||||||
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
||||||
let pmMentions = parseMentions(from: pm.content)
|
let pmMentions = parseMentions(from: pm.content)
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
id: pm.messageID,
|
id: pm.messageID,
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
content: pm.content,
|
content: pm.content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: nickname,
|
recipientNickname: nickname,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
mentions: pmMentions.isEmpty ? nil : pmMentions
|
mentions: pmMentions.isEmpty ? nil : pmMentions
|
||||||
)
|
)
|
||||||
handlePrivateMessage(msg)
|
handlePrivateMessage(msg)
|
||||||
// Send delivery ACK back over BLE
|
// Send delivery ACK back over BLE
|
||||||
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
|
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
|
||||||
|
|
||||||
case .delivered:
|
case .delivered:
|
||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname,
|
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
|
||||||
let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return }
|
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||||||
|
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
|
||||||
// Don't downgrade from .read to .delivered
|
objectWillChange.send()
|
||||||
if case .read = privateChats[foundPeerID]?[idx].deliveryStatus { return }
|
}
|
||||||
|
}
|
||||||
privateChats[foundPeerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
|
|
||||||
objectWillChange.send()
|
|
||||||
|
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname,
|
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
|
||||||
let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return }
|
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
|
||||||
|
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
|
||||||
// Explicitly unwrap and re-assign to ensure the @Published setter is called
|
objectWillChange.send()
|
||||||
if let messages = privateChats[foundPeerID], idx < messages.count {
|
}
|
||||||
messages[idx].deliveryStatus = .read(by: name, at: Date())
|
|
||||||
privateChats[foundPeerID] = messages
|
|
||||||
privateChatManager.objectWillChange.send()
|
|
||||||
objectWillChange.send()
|
|
||||||
}
|
}
|
||||||
case .verifyChallenge:
|
case .verifyChallenge:
|
||||||
// Parse and respond
|
// Parse and respond
|
||||||
@@ -3173,7 +3159,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.trimmed
|
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let publicMentions = parseMentions(from: normalized)
|
let publicMentions = parseMentions(from: normalized)
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
@@ -3346,25 +3332,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
self.scheduleNetworkEmptyTimer()
|
self.scheduleNetworkEmptyTimer()
|
||||||
} else {
|
} else {
|
||||||
self.invalidateNetworkEmptyTimer()
|
self.invalidateNetworkEmptyTimer()
|
||||||
// Don't trim recentlySeenPeers here - let timers handle cleanup.
|
// Trim out peers we no longer observe before comparing for new arrivals
|
||||||
// Trimming immediately causes peers to be treated as "new" when they
|
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||||||
// 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 {
|
||||||
// Rate limit: max one notification per 5 minutes
|
self.lastNetworkNotificationTime = Date()
|
||||||
let cooldown = TransportConfig.networkNotificationCooldownSeconds
|
self.recentlySeenPeers.formUnion(newPeers)
|
||||||
if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown {
|
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||||
// Only mark peers as seen when we actually notify about them
|
SecureLogger.info(
|
||||||
// This ensures peers arriving during cooldown will be included in the next notification
|
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||||
self.recentlySeenPeers.formUnion(newPeers)
|
category: .session
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3781,8 +3760,10 @@ 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.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) {
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
publicMessagePipeline.enqueue(finalMessage)
|
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||||
|
publicMessagePipeline.enqueue(finalMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Tor
|
import Tor
|
||||||
|
|
||||||
@@ -57,9 +56,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),
|
|
||||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
@@ -78,7 +75,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].trimmed
|
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
geoNicknames[event.pubkey.lowercased()] = nick
|
geoNicknames[event.pubkey.lowercased()] = nick
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +86,6 @@ extension ChatViewModel {
|
|||||||
// Update participants last-seen for this pubkey
|
// Update participants last-seen for this pubkey
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||||
|
|
||||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
||||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
let hasTeleportTag = event.tags.contains(where: { tag in
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||||
@@ -116,8 +108,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||||
let content = event.content.trimmed
|
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
// 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())
|
||||||
@@ -132,28 +124,20 @@ extension ChatViewModel {
|
|||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// BCH-01-012: Check blocking before any notifications
|
|
||||||
// handlePublicMessage has its own blocking check but returns silently,
|
|
||||||
// so we must also guard checkForMentions to prevent notification bypass
|
|
||||||
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
|
||||||
|
|
||||||
handlePublicMessage(msg)
|
handlePublicMessage(msg)
|
||||||
|
checkForMentions(msg)
|
||||||
// Only check mentions and send haptic if sender is not blocked
|
sendHapticFeedback(for: msg)
|
||||||
if !isBlocked {
|
|
||||||
checkForMentions(msg)
|
|
||||||
sendHapticFeedback(for: msg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
content.hasPrefix("bitchat1:"),
|
||||||
|
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 {
|
||||||
@@ -193,7 +177,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.trimmed.isEmpty }.count
|
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).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)
|
||||||
}
|
}
|
||||||
@@ -255,10 +239,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
func handleNostrEvent(_ event: NostrEvent) {
|
||||||
guard event.isValidSignature() else { return }
|
// Only handle ephemeral kind 20000 with matching tag
|
||||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
|
||||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
|
||||||
|
|
||||||
// Deduplicate
|
// Deduplicate
|
||||||
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
||||||
@@ -268,11 +250,6 @@ extension ChatViewModel {
|
|||||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||||
|
|
||||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
// Track teleport tag for participants – only our format ["t", "teleport"]
|
||||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
let hasTeleportTag: Bool = event.tags.contains { tag in
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||||
@@ -296,9 +273,6 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey
|
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
|
||||||
|
|
||||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||||
if isSelf {
|
if isSelf {
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
@@ -309,26 +283,28 @@ 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 {
|
||||||
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
|
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
geoNicknames[event.pubkey.lowercased()] = nick
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this pubkey is blocked, skip mapping, participants, and timeline
|
||||||
|
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store mapping for geohash DM initiation
|
// Store mapping for geohash DM initiation
|
||||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||||
|
|
||||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
// Update participants last-seen for this pubkey
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||||
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" }),
|
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
||||||
teleTag.count >= 2,
|
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
teleTag[1] == "teleport",
|
|
||||||
content.trimmed.isEmpty {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +346,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -384,7 +359,9 @@ 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 let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
guard content.hasPrefix("bitchat1:"),
|
||||||
|
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 {
|
||||||
@@ -487,9 +464,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 else { return }
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
|
||||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
|
||||||
|
|
||||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||||
let existingCount = participantTracker.participantCount(for: gh)
|
let existingCount = participantTracker.participantCount(for: gh)
|
||||||
@@ -498,8 +473,9 @@ 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
|
||||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
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 }
|
||||||
|
|
||||||
@@ -604,7 +580,6 @@ 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 {
|
||||||
@@ -622,7 +597,8 @@ 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 packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
|
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -633,23 +609,24 @@ 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 {
|
||||||
let payload = NoisePayload.decode(packet.payload) {
|
if 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,19 +779,6 @@ extension ChatViewModel {
|
|||||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
|
||||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
|
||||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
|
||||||
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
|
||||||
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
|
|
||||||
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
|
||||||
guard encoded.count <= maxEncoded else { return nil }
|
|
||||||
guard let packetData = Self.base64URLDecode(encoded),
|
|
||||||
packetData.count <= maxBytes
|
|
||||||
else { return nil }
|
|
||||||
return BitchatPacket.from(packetData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||||
|
|
||||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension ChatViewModel {
|
extension ChatViewModel {
|
||||||
@@ -320,7 +319,7 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = selectedPrivateChatPeer
|
let targetPeer = selectedPrivateChatPeer
|
||||||
let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer)
|
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
|
|
||||||
@@ -365,36 +364,6 @@ 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 {
|
||||||
@@ -429,7 +398,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: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
let message = self.enqueueMediaMessage(content: "[image] \(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)
|
||||||
@@ -548,19 +517,20 @@ 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 categories: [MimeType.Category] = [.audio, .image, .file]
|
let prefixes = ["[voice] ", "[image] ", "[file] "]
|
||||||
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
|
||||||
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
"images/outgoing", "images/incoming",
|
||||||
let base = try? applicationFilesDirectory(),
|
"files/outgoing", "files/incoming"]
|
||||||
// Security: Extract only the last path component to prevent directory traversal
|
|
||||||
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
|
||||||
safeFilename != "." && safeFilename != ".."
|
let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
else {
|
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
|
||||||
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)
|
||||||
|
|
||||||
@@ -770,11 +740,15 @@ extension ChatViewModel {
|
|||||||
|
|
||||||
if isViewing {
|
if isViewing {
|
||||||
// Mark read immediately if viewing
|
// Mark read immediately if viewing
|
||||||
// Use the incoming peerID directly - it has the established Noise session.
|
// Use router to send read receipt
|
||||||
// Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session.
|
|
||||||
// Use meshService directly (not messageRouter) so it queues if peer disconnects.
|
|
||||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||||
meshService.sendReadReceipt(receipt, to: peerID)
|
if let key = noiseKey {
|
||||||
|
// Send via router to stable key if available (preferred for persistence/Nostr fallback)
|
||||||
|
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
||||||
|
} else {
|
||||||
|
// Fallback to mesh direct
|
||||||
|
meshService.sendReadReceipt(receipt, to: peerID)
|
||||||
|
}
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
} else {
|
} else {
|
||||||
// Notify
|
// Notify
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleTorWillRestart() {
|
@objc func handleTorWillRestart() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.torRestartPending = true
|
self.torRestartPending = true
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
|
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct PublicTimelineStore {
|
struct PublicTimelineStore {
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -86,6 +86,10 @@ struct AppInfoView: View {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Warning {
|
||||||
|
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||||
|
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -188,6 +192,24 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
FeatureRow(info: Strings.Privacy.panic)
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warning
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
SectionHeader(Strings.Warning.title)
|
||||||
|
.foregroundColor(Color.red)
|
||||||
|
|
||||||
|
Text(Strings.Warning.message)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(Color.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
|
||||||
|
.padding(.top)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct DeliveryStatusView: View {
|
struct DeliveryStatusView: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|||||||
@@ -15,22 +15,10 @@ struct PaymentChipView: View {
|
|||||||
enum PaymentType {
|
enum PaymentType {
|
||||||
case cashu(String)
|
case cashu(String)
|
||||||
case lightning(String)
|
case lightning(String)
|
||||||
|
|
||||||
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
||||||
|
|
||||||
private static func cashuURL(from link: String) -> URL? {
|
|
||||||
if let url = URL(string: link), url.scheme != nil {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
|
|
||||||
return URL(string: "cashu:\(enc)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var url: URL? {
|
var url: URL? {
|
||||||
switch self {
|
switch self {
|
||||||
case .cashu(let link):
|
case .cashu(let link), .lightning(let link):
|
||||||
return Self.cashuURL(from: link)
|
|
||||||
case .lightning(let link):
|
|
||||||
return URL(string: link)
|
return URL(string: link)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,13 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct TextMessageView: View {
|
struct TextMessageView: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||||
@EnvironmentObject private var viewModel: ChatViewModel
|
@EnvironmentObject private var viewModel: ChatViewModel
|
||||||
|
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
@State private var expandedMessageIDs: Set<String> = []
|
@Binding var expandedMessageIDs: Set<String>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -67,12 +66,14 @@ 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)
|
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(EmptyView())
|
.listRowBackground(EmptyView())
|
||||||
@@ -80,7 +81,7 @@ struct TextMessageView: View {
|
|||||||
.environment(\.colorScheme, .light)
|
.environment(\.colorScheme, .light)
|
||||||
|
|
||||||
List {
|
List {
|
||||||
TextMessageView(message: .preview)
|
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(EmptyView())
|
.listRowBackground(EmptyView())
|
||||||
|
|||||||
+1076
-99
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct FingerprintView: View {
|
struct FingerprintView: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
@@ -40,31 +40,10 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||||
// High-precision uncertainty: if count is 0 for high-precision levels,
|
|
||||||
// show "?" because presence broadcasting is disabled for privacy.
|
|
||||||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
|
||||||
if isHighPrecision && count == 0 {
|
|
||||||
return String(
|
|
||||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
|
||||||
locale: .current,
|
|
||||||
level.displayName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rowTitle(label: level.displayName, count: count)
|
return rowTitle(label: level.displayName, count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||||
// Check precision for bookmarks too
|
|
||||||
let len = geohash.count
|
|
||||||
// Neighborhood=6, Block=7, Building=8+
|
|
||||||
let isHighPrecision = (len >= 6)
|
|
||||||
if isHighPrecision && count == 0 {
|
|
||||||
return String(
|
|
||||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
|
||||||
locale: .current,
|
|
||||||
"#\(geohash)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rowTitle(label: "#\(geohash)", count: count)
|
return rowTitle(label: "#\(geohash)", count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +104,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, action: SystemSettings.location.open)
|
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
case LocationChannelManager.PermissionState.authorized:
|
case LocationChannelManager.PermissionState.authorized:
|
||||||
@@ -246,7 +225,9 @@ struct LocationChannelsSheet: View {
|
|||||||
sectionDivider
|
sectionDivider
|
||||||
torToggleSection
|
torToggleSection
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
Button(action: SystemSettings.location.open) {
|
Button(action: {
|
||||||
|
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))
|
||||||
@@ -302,7 +283,7 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let normalized = customGeohash
|
let normalized = customGeohash
|
||||||
.trimmed
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.lowercased()
|
.lowercased()
|
||||||
.replacingOccurrences(of: "#", with: "")
|
.replacingOccurrences(of: "#", with: "")
|
||||||
let isValid = validateGeohash(normalized)
|
let isValid = validateGeohash(normalized)
|
||||||
@@ -449,7 +430,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]).trimmed
|
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
|
||||||
let suffix = String(s[idx...])
|
let suffix = String(s[idx...])
|
||||||
return (prefix, suffix)
|
return (prefix, suffix)
|
||||||
}
|
}
|
||||||
@@ -620,3 +601,18 @@ 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,15 +12,11 @@ struct LocationNotesView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var draft: String = ""
|
@State private var draft: String = ""
|
||||||
|
|
||||||
init(
|
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
|
||||||
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: manager ?? LocationNotesManager(geohash: gh))
|
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||||
@@ -264,13 +260,14 @@ struct LocationNotesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func send() {
|
private func send() {
|
||||||
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
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.trimmed.isEmpty && manager.state != .noRelays
|
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timestamp Formatting
|
// MARK: - Timestamp Formatting
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
//
|
|
||||||
// MediaMessageView.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Created by Islam on 30/03/2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
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,10 +34,24 @@ 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 {
|
||||||
guard playback.duration.isFinite else { return "--:--" }
|
playback.isPlaying ? currentText + "/" + durationText : durationText
|
||||||
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,5 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct MeshPeerList: View {
|
struct MeshPeerList: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
|
|||||||
@@ -1,449 +0,0 @@
|
|||||||
//
|
|
||||||
// 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()
|
|
||||||
//}
|
|
||||||
@@ -5,6 +5,21 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private enum RegexCache {
|
||||||
|
static let cashu: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lightningScheme: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||||
|
}()
|
||||||
|
static let bolt11: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lnurl: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
||||||
func hasVeryLongToken(threshold: Int) -> Bool {
|
func hasVeryLongToken(threshold: Int) -> Bool {
|
||||||
@@ -23,7 +38,7 @@ extension String {
|
|||||||
|
|
||||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||||
let regex = MessageFormattingEngine.Patterns.cashu
|
let regex = RegexCache.cashu
|
||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
var found: [String] = []
|
var found: [String] = []
|
||||||
@@ -44,19 +59,19 @@ extension String {
|
|||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let full = NSRange(location: 0, length: ns.length)
|
let full = NSRange(location: 0, length: ns.length)
|
||||||
// lightning: scheme
|
// lightning: scheme
|
||||||
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {
|
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append(s)
|
results.append(s)
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// BOLT11
|
// BOLT11
|
||||||
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {
|
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// LNURL bech32
|
// LNURL bech32
|
||||||
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {
|
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension BitchatMessage {
|
extension BitchatMessage {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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.
|
Before Width: | Height: | Size: 14 KiB |
@@ -6,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class PreviewKeychainManager: KeychainManagerProtocol {
|
final class PreviewKeychainManager: KeychainManagerProtocol {
|
||||||
@@ -42,19 +41,6 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
storage["identity_noiseStaticKey"] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-009: New methods with proper error classification
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
if let data = storage[key] {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .itemNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
storage[key] = keyData
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user