mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 17:45:19 +00:00
Compare commits
21
Commits
v1.4.0
...
file-transfer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991def1deb | ||
|
|
ac7feb380e | ||
|
|
57c5d81981 | ||
|
|
fa04e801b6 | ||
|
|
3c21783eae | ||
|
|
b196a55c37 | ||
|
|
ade700e7f0 | ||
|
|
ea7b90c074 | ||
|
|
5486fc6a69 | ||
|
|
7415039183 | ||
|
|
7b73728f1d | ||
|
|
1ea90295ce | ||
|
|
c9fac83433 | ||
|
|
5b799e1a50 | ||
|
|
9cbdb0a764 | ||
|
|
d1682db79b | ||
|
|
6a6504c6f2 | ||
|
|
ea8d51a36b | ||
|
|
347ce5ece4 | ||
|
|
7c4bde59b9 | ||
|
|
2ac01db9c4 |
@@ -75,3 +75,6 @@ TestResult.xcresult/
|
|||||||
*.xcresult/
|
*.xcresult/
|
||||||
build.log
|
build.log
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Local configs
|
||||||
|
Local.xcconfig
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#include "Release.xcconfig"
|
||||||
|
|
||||||
|
// Optional include of local configs
|
||||||
|
#include? "Local.xcconfig"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Your Apple Developer Team ID - https://stackoverflow.com/a/18727947
|
||||||
|
DEVELOPMENT_TEAM = ABC123
|
||||||
|
|
||||||
|
// Unique bundle id to be able to register and run locally
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
MARKETING_VERSION = 1.4.0
|
||||||
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0
|
||||||
|
SWIFT_VERSION = 5.0
|
||||||
|
|
||||||
|
DEVELOPMENT_TEAM = L3N5LHJD5Y
|
||||||
|
CODE_SIGN_STYLE = Automatic
|
||||||
|
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||||
@@ -15,6 +15,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
|
.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")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
@@ -22,6 +23,7 @@ let package = Package(
|
|||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.target(name: "TorC"),
|
.target(name: "TorC"),
|
||||||
.target(name: "tor-nolzma")
|
.target(name: "tor-nolzma")
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -94,45 +94,25 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### Option 1: Using XcodeGen (Recommended)
|
### Option 1: Using Xcode
|
||||||
|
|
||||||
1. Install XcodeGen if you haven't already:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install xcodegen
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Generate the Xcode project:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
cd bitchat
|
||||||
xcodegen generate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Open the generated project:
|
|
||||||
```bash
|
|
||||||
open bitchat.xcodeproj
|
open bitchat.xcodeproj
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Using Swift Package Manager
|
To run on a device there're a few steps to prepare the code:
|
||||||
|
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
||||||
|
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
||||||
|
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
||||||
|
- Entitlements need to be updated manually (TODO: Automate):
|
||||||
|
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
||||||
|
|
||||||
1. Open the project in Xcode:
|
### Option 2: Using `just`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
brew install just
|
||||||
open Package.swift
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Select your target device and run
|
|
||||||
|
|
||||||
### Option 3: Manual Xcode Project
|
|
||||||
|
|
||||||
1. Open Xcode and create a new iOS/macOS App
|
|
||||||
2. Copy all Swift files from the `bitchat` directory into your project
|
|
||||||
3. Update Info.plist with Bluetooth permissions
|
|
||||||
4. Set deployment target to iOS 16.0 / macOS 13.0
|
|
||||||
|
|
||||||
### Option 4: just
|
|
||||||
|
|
||||||
Want to try this on macos: `just run` will set it up and run from source.
|
Want to try this on macos: `just run` will set it up and run from source.
|
||||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||||
|
|||||||
+102
-192
@@ -11,9 +11,10 @@
|
|||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||||
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||||
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||||
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 */
|
||||||
@@ -26,13 +27,6 @@
|
|||||||
remoteGlobalIDString = AF077EA0474EDEDE2C72716C;
|
remoteGlobalIDString = AF077EA0474EDEDE2C72716C;
|
||||||
remoteInfo = bitchat_iOS;
|
remoteInfo = bitchat_iOS;
|
||||||
};
|
};
|
||||||
E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = 57CA17A36A2532A6CFF367BB;
|
|
||||||
remoteInfo = bitchatShareExtension;
|
|
||||||
};
|
|
||||||
FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {
|
FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
||||||
@@ -48,7 +42,6 @@
|
|||||||
dstPath = "";
|
dstPath = "";
|
||||||
dstSubfolder = PlugIns;
|
dstSubfolder = PlugIns;
|
||||||
files = (
|
files = (
|
||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */,
|
|
||||||
);
|
);
|
||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
};
|
};
|
||||||
@@ -58,7 +51,6 @@
|
|||||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
||||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -66,13 +58,6 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Services/TransportConfig.swift,
|
|
||||||
);
|
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
|
||||||
};
|
|
||||||
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = {
|
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
@@ -89,20 +74,12 @@
|
|||||||
);
|
);
|
||||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||||
};
|
};
|
||||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
ShareViewController.swift,
|
|
||||||
);
|
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
A6E32C972E762EA70032EA8A /* bitchat */ = {
|
A6E32C972E762EA70032EA8A /* bitchat */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */,
|
|
||||||
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */,
|
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */,
|
||||||
A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */,
|
A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */,
|
||||||
);
|
);
|
||||||
@@ -111,9 +88,6 @@
|
|||||||
};
|
};
|
||||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = {
|
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */,
|
|
||||||
);
|
|
||||||
path = bitchatShareExtension;
|
path = bitchatShareExtension;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -122,12 +96,18 @@
|
|||||||
path = bitchatTests;
|
path = bitchatTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A6E367C92E76469E0032EA8A /* Configs */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Configs;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
31F6FDADA63050361C14F3A1 /* Frameworks */ = {
|
31F6FDADA63050361C14F3A1 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
@@ -136,6 +116,7 @@
|
|||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
@@ -160,6 +141,7 @@
|
|||||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
|
||||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||||
|
A6E367C92E76469E0032EA8A /* Configs */,
|
||||||
0481A3542E6D877600FC845E /* Frameworks */,
|
0481A3542E6D877600FC845E /* Frameworks */,
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
);
|
);
|
||||||
@@ -170,7 +152,6 @@
|
|||||||
children = (
|
children = (
|
||||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */,
|
96D0D41CA19EE5A772AA8434 /* bitchat.app */,
|
||||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */,
|
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */,
|
||||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */,
|
|
||||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
|
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
|
||||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
|
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
|
||||||
);
|
);
|
||||||
@@ -196,6 +177,7 @@
|
|||||||
name = bitchat_macOS;
|
name = bitchat_macOS;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -220,19 +202,6 @@
|
|||||||
productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;
|
productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
};
|
};
|
||||||
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
|
|
||||||
buildPhases = (
|
|
||||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
name = bitchatShareExtension;
|
|
||||||
productName = bitchatShareExtension;
|
|
||||||
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
|
|
||||||
productType = "com.apple.product-type.app-extension";
|
|
||||||
};
|
|
||||||
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = {
|
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||||
@@ -263,15 +232,13 @@
|
|||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
|
||||||
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */,
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
A6E32C972E762EA70032EA8A /* bitchat */,
|
A6E32C972E762EA70032EA8A /* bitchat */,
|
||||||
);
|
);
|
||||||
name = bitchat_iOS;
|
name = bitchat_iOS;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -285,28 +252,6 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1640;
|
LastUpgradeCheck = 1640;
|
||||||
TargetAttributes = {
|
|
||||||
0576A29205865664C0937536 = {
|
|
||||||
DevelopmentTeam = L3N5LHJD5Y;
|
|
||||||
ProvisioningStyle = Automatic;
|
|
||||||
};
|
|
||||||
47FF23248747DD7CB666CB91 = {
|
|
||||||
DevelopmentTeam = L3N5LHJD5Y;
|
|
||||||
ProvisioningStyle = Automatic;
|
|
||||||
};
|
|
||||||
57CA17A36A2532A6CFF367BB = {
|
|
||||||
DevelopmentTeam = L3N5LHJD5Y;
|
|
||||||
ProvisioningStyle = Automatic;
|
|
||||||
};
|
|
||||||
6CB97DF2EA57234CB3E563B8 = {
|
|
||||||
DevelopmentTeam = L3N5LHJD5Y;
|
|
||||||
ProvisioningStyle = Automatic;
|
|
||||||
};
|
|
||||||
AF077EA0474EDEDE2C72716C = {
|
|
||||||
DevelopmentTeam = L3N5LHJD5Y;
|
|
||||||
ProvisioningStyle = Automatic;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
@@ -319,12 +264,12 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */,
|
|
||||||
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,
|
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,
|
||||||
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,
|
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,
|
||||||
AF077EA0474EDEDE2C72716C /* bitchat_iOS */,
|
AF077EA0474EDEDE2C72716C /* bitchat_iOS */,
|
||||||
@@ -349,11 +294,6 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
};
|
|
||||||
137ABE739BF20ACDDF8CC605 /* Sources */ = {
|
137ABE739BF20ACDDF8CC605 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
@@ -382,11 +322,6 @@
|
|||||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||||
targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;
|
targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
|
||||||
targetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {
|
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;
|
target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;
|
||||||
@@ -397,21 +332,24 @@
|
|||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
077A5203074247CF8F766E2F /* Debug configuration for PBXNativeTarget "bitchatTests_iOS" */ = {
|
077A5203074247CF8F766E2F /* Debug configuration for PBXNativeTarget "bitchatTests_iOS" */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
|
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat";
|
||||||
};
|
};
|
||||||
@@ -419,21 +357,24 @@
|
|||||||
};
|
};
|
||||||
0DACAA261446D178EDD30ECA /* Release configuration for PBXNativeTarget "bitchatTests_iOS" */ = {
|
0DACAA261446D178EDD30ECA /* Release configuration for PBXNativeTarget "bitchatTests_iOS" */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
|
baseConfigurationReferenceRelativePath = Release.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat";
|
||||||
};
|
};
|
||||||
@@ -441,153 +382,140 @@
|
|||||||
};
|
};
|
||||||
147FDAE548082D5B921C6F0B /* Release configuration for PBXNativeTarget "bitchatTests_macOS" */ = {
|
147FDAE548082D5B921C6F0B /* Release configuration for PBXNativeTarget "bitchatTests_macOS" */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
|
baseConfigurationReferenceRelativePath = Release.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
"@loader_path/../Frameworks",
|
"@loader_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
|
||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
"@executable_path/../../Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.4.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
|
||||||
SUPPORTS_MACCATALYST = NO;
|
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = {
|
702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
|
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.0;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
7FA2BADBF3B325125030CAB1 /* Debug configuration for PBXNativeTarget "bitchatTests_macOS" */ = {
|
7FA2BADBF3B325125030CAB1 /* Debug configuration for PBXNativeTarget "bitchatTests_macOS" */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
|
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
"@loader_path/../Frameworks",
|
"@loader_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
B36671AEACCBF92BE10852E9 /* Release configuration for PBXNativeTarget "bitchat_iOS" */ = {
|
B36671AEACCBF92BE10852E9 /* Release configuration for PBXNativeTarget "bitchat_iOS" */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
|
baseConfigurationReferenceRelativePath = Release.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.0;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
BB044400A0F06B93F22D0D55 /* Release configuration for PBXNativeTarget "bitchat_macOS" */ = {
|
BB044400A0F06B93F22D0D55 /* Release configuration for PBXNativeTarget "bitchat_macOS" */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
|
baseConfigurationReferenceRelativePath = Release.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
|
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -595,13 +523,14 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.4.0;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -639,10 +568,9 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = L3N5LHJD5Y;
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -654,29 +582,32 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
CC79F65842D42034ACEE79B7 /* Debug configuration for PBXNativeTarget "bitchat_macOS" */ = {
|
CC79F65842D42034ACEE79B7 /* Debug configuration for PBXNativeTarget "bitchat_macOS" */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
|
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
|
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -684,13 +615,14 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.4.0;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -728,10 +660,9 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = L3N5LHJD5Y;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -749,44 +680,16 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
|
||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
"@executable_path/../../Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.4.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
|
||||||
SUPPORTS_MACCATALYST = NO;
|
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -833,16 +736,15 @@
|
|||||||
);
|
);
|
||||||
defaultConfigurationName = Debug;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */,
|
|
||||||
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */,
|
|
||||||
);
|
|
||||||
defaultConfigurationName = Debug;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/BitLogger;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = {
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
@@ -860,6 +762,14 @@
|
|||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
productName = P256K;
|
productName = P256K;
|
||||||
};
|
};
|
||||||
|
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = BitLogger;
|
||||||
|
};
|
||||||
|
A6E3E5712E7703760032EA8A /* BitLogger */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = BitLogger;
|
||||||
|
};
|
||||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
|
|||||||
+19
-12
@@ -11,6 +11,9 @@ import UserNotifications
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
@StateObject private var chatViewModel: ChatViewModel
|
@StateObject private var chatViewModel: ChatViewModel
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@@ -54,8 +57,8 @@ struct BitchatApp: App {
|
|||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#endif
|
#endif
|
||||||
// Spin up Tor early; all internet will gate on Tor 100%
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
TorManager.shared.startIfNeeded()
|
NetworkActivationService.shared.start()
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -67,7 +70,7 @@ struct BitchatApp: App {
|
|||||||
switch newPhase {
|
switch newPhase {
|
||||||
case .background:
|
case .background:
|
||||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||||
// Optionally nudge Tor to dormant to save power
|
// Always send Tor to dormant on background for a clean restart later.
|
||||||
TorManager.shared.setAppForeground(false)
|
TorManager.shared.setAppForeground(false)
|
||||||
TorManager.shared.goDormantOnBackground()
|
TorManager.shared.goDormantOnBackground()
|
||||||
// Stop geohash sampling while backgrounded
|
// Stop geohash sampling while backgrounded
|
||||||
@@ -84,18 +87,22 @@ struct BitchatApp: App {
|
|||||||
// On initial cold launch, Tor was just started in onAppear.
|
// On initial cold launch, Tor was just started in onAppear.
|
||||||
// Skip the deterministic restart the first time we become active.
|
// Skip the deterministic restart the first time we become active.
|
||||||
if didHandleInitialActive && didEnterBackground {
|
if didHandleInitialActive && didEnterBackground {
|
||||||
TorManager.shared.ensureRunningOnForeground()
|
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||||
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
didHandleInitialActive = true
|
didHandleInitialActive = true
|
||||||
}
|
}
|
||||||
didEnterBackground = false
|
didEnterBackground = false
|
||||||
Task.detached {
|
if TorManager.shared.isAutoStartAllowed() {
|
||||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
Task.detached {
|
||||||
await MainActor.run {
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
// Rebuild proxied sessions to bind to the live Tor after readiness
|
await MainActor.run {
|
||||||
TorURLSession.shared.rebuild()
|
// Rebuild proxied sessions to bind to the live Tor after readiness
|
||||||
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
TorURLSession.shared.rebuild()
|
||||||
NostrRelayManager.shared.resetAllConnections()
|
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
||||||
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
@@ -130,7 +137,7 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
private func checkForSharedContent() {
|
private func checkForSharedContent() {
|
||||||
// Check app group for shared content from extension
|
// Check app group for shared content from extension
|
||||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import AVFoundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
|
private var recorder: AVAudioRecorder?
|
||||||
|
private(set) var isRecording = false
|
||||||
|
|
||||||
|
func startRecording(to url: URL) throws {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||||
|
try session.setActive(true)
|
||||||
|
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 44100,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderBitRateKey: 32000
|
||||||
|
]
|
||||||
|
recorder = try AVAudioRecorder(url: url, settings: settings)
|
||||||
|
recorder?.delegate = self
|
||||||
|
recorder?.isMeteringEnabled = true
|
||||||
|
guard recorder?.record() == true else { throw NSError(domain: "VoiceRecorder", code: -1) }
|
||||||
|
isRecording = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording(completion: @escaping () -> Void) {
|
||||||
|
guard isRecording else { completion(); return }
|
||||||
|
// Add 500ms padding to avoid clipping
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.recorder?.stop()
|
||||||
|
self?.isRecording = false
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live metrics for visualizer
|
||||||
|
|
||||||
|
/// Returns normalized amplitude [0,1] based on average power in dB.
|
||||||
|
/// Call periodically while recording to drive a live waveform.
|
||||||
|
func pollNormalizedAmplitude() -> CGFloat {
|
||||||
|
guard let r = recorder, isRecording else { return 0 }
|
||||||
|
r.updateMeters()
|
||||||
|
// Use peak power for responsiveness, map dB [-160,0] -> linear [0,1]
|
||||||
|
let db = r.peakPower(forChannel: 0)
|
||||||
|
let linear = pow(10.0, db / 20.0) // 0..1
|
||||||
|
if linear.isNaN || linear.isInfinite { return 0 }
|
||||||
|
// Keep linear to avoid globally scaling up low amplitudes
|
||||||
|
return CGFloat(max(0, min(1, linear)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current elapsed recording time in milliseconds.
|
||||||
|
func currentTimeMs() -> Int {
|
||||||
|
guard let r = recorder, isRecording else { return 0 }
|
||||||
|
return Int(r.currentTime * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoiceRecorderPaths {
|
||||||
|
static func outgoingURL() throws -> URL {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true)
|
||||||
|
if !fm.fileExists(atPath: folder.path) {
|
||||||
|
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
let ts = ISO8601DateFormatter()
|
||||||
|
ts.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime]
|
||||||
|
let name = "voice_\(Int(Date().timeIntervalSince1970)).m4a"
|
||||||
|
return folder.appendingPathComponent(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
/// Minimal macOS-compatible stubs so the macOS target builds without AVFAudio.
|
||||||
|
final class VoiceRecorder: NSObject {
|
||||||
|
private(set) var isRecording = false
|
||||||
|
func startRecording(to url: URL) throws {
|
||||||
|
// Voice recording is iOS-only in this project
|
||||||
|
throw NSError(domain: "VoiceRecorder", code: -1, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
|
||||||
|
}
|
||||||
|
func stopRecording(completion: @escaping () -> Void) { completion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoiceRecorderPaths {
|
||||||
|
static func outgoingURL() throws -> URL {
|
||||||
|
throw NSError(domain: "VoiceRecorder", code: -2, userInfo: [NSLocalizedDescriptionKey: "Voice recording is not supported on macOS in this build."])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import Accelerate
|
||||||
|
|
||||||
|
struct WaveformCache {
|
||||||
|
static var shared = WaveformCache()
|
||||||
|
private var cache: [String: [Float]] = [:]
|
||||||
|
mutating func set(_ bins: [Float], for path: String) { cache[path] = bins }
|
||||||
|
func get(_ path: String) -> [Float]? { cache[path] }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WaveformExtractor {
|
||||||
|
static func extractBins(url: URL, binCount: Int = 120) -> [Float] {
|
||||||
|
if let cached = WaveformCache.shared.get(url.path) { return cached }
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
guard let track = asset.tracks(withMediaType: .audio).first else { return [] }
|
||||||
|
let reader: AVAssetReader
|
||||||
|
do { reader = try AVAssetReader(asset: asset) } catch { return [] }
|
||||||
|
let outputSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||||
|
AVLinearPCMBitDepthKey: 16,
|
||||||
|
AVLinearPCMIsBigEndianKey: false,
|
||||||
|
AVLinearPCMIsFloatKey: false,
|
||||||
|
AVLinearPCMIsNonInterleaved: false
|
||||||
|
]
|
||||||
|
let output = AVAssetReaderTrackOutput(track: track, outputSettings: outputSettings)
|
||||||
|
output.alwaysCopiesSampleData = false
|
||||||
|
if reader.canAdd(output) { reader.add(output) } else { return [] }
|
||||||
|
guard reader.startReading() else { return [] }
|
||||||
|
|
||||||
|
var samples: [Float] = []
|
||||||
|
while reader.status == .reading {
|
||||||
|
guard let buffer = output.copyNextSampleBuffer() else { break }
|
||||||
|
if let block = CMSampleBufferGetDataBuffer(buffer) {
|
||||||
|
var length = 0
|
||||||
|
var dataPointer: UnsafeMutablePointer<Int8>?
|
||||||
|
if CMBlockBufferGetDataPointer(block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == kCMBlockBufferNoErr,
|
||||||
|
let base = dataPointer {
|
||||||
|
// 16-bit signed little endian
|
||||||
|
let count = length / 2
|
||||||
|
var floats = [Float](repeating: 0, count: count)
|
||||||
|
base.withMemoryRebound(to: Int16.self, capacity: count) { ptr in
|
||||||
|
vDSP.convertElements(of: UnsafeBufferPointer(start: ptr, count: count), to: &floats)
|
||||||
|
}
|
||||||
|
// Normalize to [-1,1]
|
||||||
|
var maxVal: Float = 32768.0
|
||||||
|
vDSP.divide(floats, maxVal, result: &floats)
|
||||||
|
samples += floats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CMSampleBufferInvalidate(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !samples.isEmpty, reader.status == .completed || reader.status == .reading else { return [] }
|
||||||
|
// Reduce to bins by RMS per window
|
||||||
|
let window = max(1, samples.count / binCount)
|
||||||
|
var bins: [Float] = []
|
||||||
|
bins.reserveCapacity(binCount)
|
||||||
|
var i = 0
|
||||||
|
while i < samples.count && bins.count < binCount {
|
||||||
|
let end = min(i + window, samples.count)
|
||||||
|
let slice = samples[i..<end]
|
||||||
|
var sum: Float = 0
|
||||||
|
vDSP_svesq(slice.map { $0 }, 1, &sum, vDSP_Length(slice.count))
|
||||||
|
let rms = sqrtf(sum / Float(slice.count))
|
||||||
|
bins.append(min(1.0, rms))
|
||||||
|
i = end
|
||||||
|
}
|
||||||
|
if bins.count < binCount {
|
||||||
|
bins += Array(repeating: 0, count: binCount - bins.count)
|
||||||
|
}
|
||||||
|
WaveformCache.shared.set(bins, for: url.path)
|
||||||
|
return bins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -90,6 +90,7 @@
|
|||||||
/// - Advanced conflict resolution
|
/// - Advanced conflict resolution
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,18 @@
|
|||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>bitchat saves received images to your photo library when you choose to save them.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>bitchat needs access to your photos to let you pick images to send.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<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>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>bitchat uses the microphone to record and send short voice notes to nearby peers.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// BitchatFilePacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// TLV encoder/decoder for file transfer payloads (images, audio, and generic files)
|
||||||
|
// v2 Spec: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (4 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (4-byte len)
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
struct BitchatFilePacket {
|
||||||
|
enum TLVType: UInt8 {
|
||||||
|
case fileName = 0x01
|
||||||
|
case fileSize = 0x02
|
||||||
|
case mimeType = 0x03
|
||||||
|
case content = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileName: String
|
||||||
|
let fileSize: UInt32
|
||||||
|
let mimeType: String
|
||||||
|
let content: Data
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
var out = Data()
|
||||||
|
|
||||||
|
// Standard TLV helper for FILE_NAME and MIME_TYPE (2-byte length)
|
||||||
|
func appendStandardTLV(_ type: TLVType, _ value: Data) {
|
||||||
|
out.append(type.rawValue)
|
||||||
|
let len = UInt16(min(value.count, 0xFFFF))
|
||||||
|
out.append(UInt8((len >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(len & 0xFF))
|
||||||
|
out.append(value.prefix(Int(len)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILE_NAME
|
||||||
|
if let nameData = fileName.data(using: .utf8) {
|
||||||
|
appendStandardTLV(.fileName, nameData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILE_SIZE (4 bytes, UInt32 BE) - v2 spec
|
||||||
|
out.append(TLVType.fileSize.rawValue)
|
||||||
|
out.append(UInt8(0)) // Length high byte = 0
|
||||||
|
out.append(UInt8(4)) // Length low byte = 4
|
||||||
|
let size32 = UInt32(min(fileSize, UInt32.max))
|
||||||
|
out.append(UInt8((size32 >> 24) & 0xFF))
|
||||||
|
out.append(UInt8((size32 >> 16) & 0xFF))
|
||||||
|
out.append(UInt8((size32 >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(size32 & 0xFF))
|
||||||
|
|
||||||
|
// MIME_TYPE
|
||||||
|
if let mimeData = mimeType.data(using: .utf8) {
|
||||||
|
appendStandardTLV(.mimeType, mimeData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONTENT (4-byte length) - v2 spec
|
||||||
|
out.append(TLVType.content.rawValue)
|
||||||
|
let contentLen = UInt32(content.count)
|
||||||
|
out.append(UInt8((contentLen >> 24) & 0xFF))
|
||||||
|
out.append(UInt8((contentLen >> 16) & 0xFF))
|
||||||
|
out.append(UInt8((contentLen >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(contentLen & 0xFF))
|
||||||
|
out.append(content)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> BitchatFilePacket? {
|
||||||
|
var idx = 0
|
||||||
|
func read(_ n: Int) -> Data? {
|
||||||
|
guard idx + n <= data.count else { return nil }
|
||||||
|
defer { idx += n }
|
||||||
|
return data.subdata(in: idx..<(idx+n))
|
||||||
|
}
|
||||||
|
func read8() -> UInt8? { read(1)?.first }
|
||||||
|
func read16() -> UInt16? {
|
||||||
|
guard let d = read(2) else { return nil }
|
||||||
|
return (UInt16(d[0]) << 8) | UInt16(d[1])
|
||||||
|
}
|
||||||
|
func read32() -> UInt32? {
|
||||||
|
guard let d = read(4) else { return nil }
|
||||||
|
return (UInt32(d[0]) << 24) | (UInt32(d[1]) << 16) | (UInt32(d[2]) << 8) | UInt32(d[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
var name: String?
|
||||||
|
var size: UInt32?
|
||||||
|
var mime: String?
|
||||||
|
var contentData = Data()
|
||||||
|
|
||||||
|
while idx < data.count {
|
||||||
|
guard let t = read8(), let tlvType = TLVType(rawValue: t) else { return nil }
|
||||||
|
|
||||||
|
// CONTENT uses 4-byte length; others use 2-byte length
|
||||||
|
let len: Int
|
||||||
|
if tlvType == .content {
|
||||||
|
guard let len32 = read32() else { return nil }
|
||||||
|
len = Int(len32)
|
||||||
|
} else {
|
||||||
|
guard let len16 = read16() else { return nil }
|
||||||
|
len = Int(len16)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard len >= 0, idx + len <= data.count else { return nil }
|
||||||
|
let val = data.subdata(in: idx..<(idx+len))
|
||||||
|
idx += len
|
||||||
|
|
||||||
|
switch tlvType {
|
||||||
|
case .fileName:
|
||||||
|
name = String(data: val, encoding: .utf8)
|
||||||
|
case .fileSize:
|
||||||
|
guard len == 4 else { return nil } // v2 spec: 4 bytes
|
||||||
|
var s: UInt32 = 0
|
||||||
|
for b in val { s = (s << 8) | UInt32(b) }
|
||||||
|
size = s
|
||||||
|
case .mimeType:
|
||||||
|
mime = String(data: val, encoding: .utf8)
|
||||||
|
case .content:
|
||||||
|
// Expect single CONTENT TLV; if multiple, concatenate defensively
|
||||||
|
contentData.append(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
guard !contentData.isEmpty else { return nil }
|
||||||
|
let finalSize = size ?? UInt32(contentData.count)
|
||||||
|
let finalName = name ?? "file"
|
||||||
|
let finalMime = mime ?? "application/octet-stream"
|
||||||
|
return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: contentData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Data { func sha256Hex() -> String { SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined() } }
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//
|
||||||
|
// BitchatMessage.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents a user-visible message in the BitChat system.
|
||||||
|
/// Handles both broadcast messages and private encrypted messages,
|
||||||
|
/// with support for mentions, replies, and delivery tracking.
|
||||||
|
/// - Note: This is the primary data model for chat messages
|
||||||
|
final class BitchatMessage: Codable {
|
||||||
|
let id: String
|
||||||
|
let sender: String
|
||||||
|
let content: String
|
||||||
|
let timestamp: Date
|
||||||
|
let isRelay: Bool
|
||||||
|
let originalSender: String?
|
||||||
|
let isPrivate: Bool
|
||||||
|
let recipientNickname: String?
|
||||||
|
let senderPeerID: String?
|
||||||
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
|
// Cached formatted text (not included in Codable)
|
||||||
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||||
|
|
||||||
|
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||||
|
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||||
|
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codable implementation
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, sender, content, timestamp, isRelay, originalSender
|
||||||
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
||||||
|
self.id = id ?? UUID().uuidString
|
||||||
|
self.sender = sender
|
||||||
|
self.content = content
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.isRelay = isRelay
|
||||||
|
self.originalSender = originalSender
|
||||||
|
self.isPrivate = isPrivate
|
||||||
|
self.recipientNickname = recipientNickname
|
||||||
|
self.senderPeerID = senderPeerID
|
||||||
|
self.mentions = mentions
|
||||||
|
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Equatable Conformance
|
||||||
|
|
||||||
|
extension BitchatMessage: Equatable {
|
||||||
|
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||||
|
return lhs.id == rhs.id &&
|
||||||
|
lhs.sender == rhs.sender &&
|
||||||
|
lhs.content == rhs.content &&
|
||||||
|
lhs.timestamp == rhs.timestamp &&
|
||||||
|
lhs.isRelay == rhs.isRelay &&
|
||||||
|
lhs.originalSender == rhs.originalSender &&
|
||||||
|
lhs.isPrivate == rhs.isPrivate &&
|
||||||
|
lhs.recipientNickname == rhs.recipientNickname &&
|
||||||
|
lhs.senderPeerID == rhs.senderPeerID &&
|
||||||
|
lhs.mentions == rhs.mentions &&
|
||||||
|
lhs.deliveryStatus == rhs.deliveryStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binary encoding
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
func toBinaryPayload() -> Data? {
|
||||||
|
var data = Data()
|
||||||
|
|
||||||
|
// Message format:
|
||||||
|
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
|
||||||
|
// - Timestamp: 8 bytes (seconds since epoch)
|
||||||
|
// - ID length: 1 byte
|
||||||
|
// - ID: variable
|
||||||
|
// - Sender length: 1 byte
|
||||||
|
// - Sender: variable
|
||||||
|
// - Content length: 2 bytes
|
||||||
|
// - Content: variable
|
||||||
|
// Optional fields based on flags:
|
||||||
|
// - Original sender length + data
|
||||||
|
// - Recipient nickname length + data
|
||||||
|
// - Sender peer ID length + data
|
||||||
|
// - Mentions array
|
||||||
|
|
||||||
|
var flags: UInt8 = 0
|
||||||
|
if isRelay { flags |= 0x01 }
|
||||||
|
if isPrivate { flags |= 0x02 }
|
||||||
|
if originalSender != nil { flags |= 0x04 }
|
||||||
|
if recipientNickname != nil { flags |= 0x08 }
|
||||||
|
if senderPeerID != nil { flags |= 0x10 }
|
||||||
|
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||||
|
|
||||||
|
data.append(flags)
|
||||||
|
|
||||||
|
// Timestamp (in milliseconds)
|
||||||
|
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
|
||||||
|
// Encode as 8 bytes, big-endian
|
||||||
|
for i in (0..<8).reversed() {
|
||||||
|
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID
|
||||||
|
if let idData = id.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(idData.count, 255)))
|
||||||
|
data.append(idData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender
|
||||||
|
if let senderData = sender.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(senderData.count, 255)))
|
||||||
|
data.append(senderData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if let contentData = content.data(using: .utf8) {
|
||||||
|
let length = UInt16(min(contentData.count, 65535))
|
||||||
|
// Encode length as 2 bytes, big-endian
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(length & 0xFF))
|
||||||
|
data.append(contentData.prefix(Int(length)))
|
||||||
|
} else {
|
||||||
|
data.append(contentsOf: [0, 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(origData.count, 255)))
|
||||||
|
data.append(origData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(recipData.count, 255)))
|
||||||
|
data.append(recipData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(peerData.count, 255)))
|
||||||
|
data.append(peerData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mentions array
|
||||||
|
if let mentions = mentions {
|
||||||
|
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
|
||||||
|
for mention in mentions.prefix(255) {
|
||||||
|
if let mentionData = mention.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(mentionData.count, 255)))
|
||||||
|
data.append(mentionData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init?(_ data: Data) {
|
||||||
|
// Create an immutable copy to prevent threading issues
|
||||||
|
let dataCopy = Data(data)
|
||||||
|
|
||||||
|
|
||||||
|
guard dataCopy.count >= 13 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let flags = dataCopy[offset]; offset += 1
|
||||||
|
let isRelay = (flags & 0x01) != 0
|
||||||
|
let isPrivate = (flags & 0x02) != 0
|
||||||
|
let hasOriginalSender = (flags & 0x04) != 0
|
||||||
|
let hasRecipientNickname = (flags & 0x08) != 0
|
||||||
|
let hasSenderPeerID = (flags & 0x10) != 0
|
||||||
|
let hasMentions = (flags & 0x20) != 0
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
guard offset + 8 <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let timestampData = dataCopy[offset..<offset+8]
|
||||||
|
let timestampMillis = timestampData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
offset += 8
|
||||||
|
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
|
||||||
|
|
||||||
|
// ID
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let idLength = Int(dataCopy[offset]); offset += 1
|
||||||
|
guard offset + idLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
|
||||||
|
offset += idLength
|
||||||
|
|
||||||
|
// Sender
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let senderLength = Int(dataCopy[offset]); offset += 1
|
||||||
|
guard offset + senderLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
|
||||||
|
offset += senderLength
|
||||||
|
|
||||||
|
// Content
|
||||||
|
guard offset + 2 <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let contentLengthData = dataCopy[offset..<offset+2]
|
||||||
|
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt16(byte)
|
||||||
|
})
|
||||||
|
offset += 2
|
||||||
|
guard offset + contentLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
|
||||||
|
offset += contentLength
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
var originalSender: String?
|
||||||
|
if hasOriginalSender && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipientNickname: String?
|
||||||
|
if hasRecipientNickname && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var senderPeerID: String?
|
||||||
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mentions array
|
||||||
|
var mentions: [String]?
|
||||||
|
if hasMentions && offset < dataCopy.count {
|
||||||
|
let mentionCount = Int(dataCopy[offset]); offset += 1
|
||||||
|
if mentionCount > 0 {
|
||||||
|
mentions = []
|
||||||
|
for _ in 0..<mentionCount {
|
||||||
|
if offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
|
||||||
|
mentions?.append(mention)
|
||||||
|
}
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
id: id,
|
||||||
|
sender: sender,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: isRelay,
|
||||||
|
originalSender: originalSender,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
recipientNickname: recipientNickname,
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
mentions: mentions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
|
||||||
|
private static let timestampFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var formattedTimestamp: String {
|
||||||
|
Self.timestampFormatter.string(from: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == BitchatMessage {
|
||||||
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
|
func cleanedAndDeduped() -> [Element] {
|
||||||
|
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||||
|
guard arr.count > 1 else {
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
var seen = Set<String>()
|
||||||
|
var dedup: [BitchatMessage] = []
|
||||||
|
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
|
if !seen.contains(m.id) {
|
||||||
|
dedup.append(m)
|
||||||
|
seen.insert(m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
//
|
||||||
|
// BitchatPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The core packet structure for all BitChat protocol messages.
|
||||||
|
/// Encapsulates all data needed for routing through the mesh network,
|
||||||
|
/// including TTL for hop limiting and optional encryption.
|
||||||
|
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
||||||
|
struct BitchatPacket: Codable {
|
||||||
|
let version: UInt8
|
||||||
|
let type: UInt8
|
||||||
|
let senderID: Data
|
||||||
|
let recipientID: Data?
|
||||||
|
let timestamp: UInt64
|
||||||
|
let payload: Data
|
||||||
|
var signature: Data?
|
||||||
|
var ttl: UInt8
|
||||||
|
|
||||||
|
// Full initialization with explicit version
|
||||||
|
init(version: UInt8, type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||||
|
self.version = version
|
||||||
|
self.type = type
|
||||||
|
self.senderID = senderID
|
||||||
|
self.recipientID = recipientID
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.payload = payload
|
||||||
|
self.signature = signature
|
||||||
|
self.ttl = ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatible constructor (defaults to v1)
|
||||||
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||||
|
self.version = 1
|
||||||
|
self.type = type
|
||||||
|
self.senderID = senderID
|
||||||
|
self.recipientID = recipientID
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.payload = payload
|
||||||
|
self.signature = signature
|
||||||
|
self.ttl = ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience initializer for new binary format
|
||||||
|
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
||||||
|
self.version = 1
|
||||||
|
self.type = type
|
||||||
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
|
var senderData = Data()
|
||||||
|
var tempID = senderID
|
||||||
|
while tempID.count >= 2 {
|
||||||
|
let hexByte = String(tempID.prefix(2))
|
||||||
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
|
senderData.append(byte)
|
||||||
|
}
|
||||||
|
tempID = String(tempID.dropFirst(2))
|
||||||
|
}
|
||||||
|
self.senderID = senderData
|
||||||
|
self.recipientID = nil
|
||||||
|
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
|
||||||
|
self.payload = payload
|
||||||
|
self.signature = nil
|
||||||
|
self.ttl = ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
var data: Data? {
|
||||||
|
BinaryProtocol.encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBinaryData(padding: Bool = true) -> Data? {
|
||||||
|
BinaryProtocol.encode(self, padding: padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible helper (defaults to padded encoding)
|
||||||
|
func toBinaryData() -> Data? {
|
||||||
|
toBinaryData(padding: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create binary representation for signing (without signature and TTL fields)
|
||||||
|
/// TTL is excluded because it changes during packet relay operations
|
||||||
|
func toBinaryDataForSigning() -> Data? {
|
||||||
|
// Create a copy without signature and with fixed TTL for signing
|
||||||
|
// TTL must be excluded because it changes during relay
|
||||||
|
let unsignedPacket = BitchatPacket(
|
||||||
|
version: version, // Preserve packet version for signing
|
||||||
|
type: type,
|
||||||
|
senderID: senderID,
|
||||||
|
recipientID: recipientID,
|
||||||
|
timestamp: timestamp,
|
||||||
|
payload: payload,
|
||||||
|
signature: nil, // Remove signature for signing
|
||||||
|
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
|
)
|
||||||
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(_ data: Data) -> BitchatPacket? {
|
||||||
|
BinaryProtocol.decode(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// MessagePadding.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Provides privacy-preserving message padding to obscure actual content length.
|
||||||
|
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
||||||
|
struct MessagePadding {
|
||||||
|
// Standard block sizes for padding
|
||||||
|
static let blockSizes = [256, 512, 1024, 2048]
|
||||||
|
|
||||||
|
// Add PKCS#7-style padding to reach target size
|
||||||
|
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
|
||||||
|
guard data.count < targetSize else { return data }
|
||||||
|
|
||||||
|
let paddingNeeded = targetSize - data.count
|
||||||
|
// Constrain to 255 to fit a single-byte pad length marker
|
||||||
|
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
|
||||||
|
|
||||||
|
var padded = data
|
||||||
|
// PKCS#7: All pad bytes are equal to the pad length
|
||||||
|
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove padding from data
|
||||||
|
static func unpad(_ data: Data) -> Data {
|
||||||
|
guard !data.isEmpty else { return data }
|
||||||
|
let last = data.last!
|
||||||
|
let paddingLength = Int(last)
|
||||||
|
// Must have at least 1 pad byte and not exceed data length
|
||||||
|
guard paddingLength > 0 && paddingLength <= data.count else { return data }
|
||||||
|
// Verify PKCS#7: all last N bytes equal to pad length
|
||||||
|
let start = data.count - paddingLength
|
||||||
|
let tail = data[start...]
|
||||||
|
for b in tail { if b != last { return data } }
|
||||||
|
return Data(data[..<start])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find optimal block size for data
|
||||||
|
static func optimalBlockSize(for dataSize: Int) -> Int {
|
||||||
|
// Account for encryption overhead (~16 bytes for AES-GCM tag)
|
||||||
|
let totalSize = dataSize + 16
|
||||||
|
|
||||||
|
// Find smallest block that fits
|
||||||
|
for blockSize in blockSizes {
|
||||||
|
if totalSize <= blockSize {
|
||||||
|
return blockSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For very large messages, just use the original size
|
||||||
|
// (will be fragmented anyway)
|
||||||
|
return dataSize
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// NoisePayload.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Helper to create typed Noise payloads
|
||||||
|
struct NoisePayload {
|
||||||
|
let type: NoisePayloadType
|
||||||
|
let data: Data
|
||||||
|
|
||||||
|
/// Encode payload with type prefix
|
||||||
|
func encode() -> Data {
|
||||||
|
var encoded = Data()
|
||||||
|
encoded.append(type.rawValue)
|
||||||
|
encoded.append(data)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode payload from data
|
||||||
|
static func decode(_ data: Data) -> NoisePayload? {
|
||||||
|
// Ensure we have at least 1 byte for the type
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely get the first byte
|
||||||
|
let firstByte = data[data.startIndex]
|
||||||
|
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a proper Data copy (not a subsequence) for thread safety
|
||||||
|
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
||||||
|
return NoisePayload(type: type, data: payloadData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// ReadReceipt.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ReadReceipt: Codable {
|
||||||
|
let originalMessageID: String
|
||||||
|
let receiptID: String
|
||||||
|
var readerID: String // Who read it
|
||||||
|
let readerNickname: String
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||||
|
self.originalMessageID = originalMessageID
|
||||||
|
self.receiptID = UUID().uuidString
|
||||||
|
self.readerID = readerID
|
||||||
|
self.readerNickname = readerNickname
|
||||||
|
self.timestamp = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// For binary decoding
|
||||||
|
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||||
|
self.originalMessageID = originalMessageID
|
||||||
|
self.receiptID = receiptID
|
||||||
|
self.readerID = readerID
|
||||||
|
self.readerNickname = readerNickname
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
try? JSONEncoder().encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> ReadReceipt? {
|
||||||
|
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binary Encoding
|
||||||
|
|
||||||
|
func toBinaryData() -> Data {
|
||||||
|
var data = Data()
|
||||||
|
data.appendUUID(originalMessageID)
|
||||||
|
data.appendUUID(receiptID)
|
||||||
|
// ReaderID as 8-byte hex string
|
||||||
|
var readerData = Data()
|
||||||
|
var tempID = readerID
|
||||||
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
|
let hexByte = String(tempID.prefix(2))
|
||||||
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
|
readerData.append(byte)
|
||||||
|
}
|
||||||
|
tempID = String(tempID.dropFirst(2))
|
||||||
|
}
|
||||||
|
while readerData.count < 8 {
|
||||||
|
readerData.append(0)
|
||||||
|
}
|
||||||
|
data.append(readerData)
|
||||||
|
data.appendDate(timestamp)
|
||||||
|
data.appendString(readerNickname)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
||||||
|
// Create defensive copy
|
||||||
|
let dataCopy = Data(data)
|
||||||
|
|
||||||
|
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
||||||
|
guard dataCopy.count >= 49 else { return nil }
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
||||||
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
|
let readerID = readerIDData.hexEncodedString()
|
||||||
|
guard InputValidator.validatePeerID(readerID) else { return nil }
|
||||||
|
|
||||||
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
||||||
|
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
||||||
|
|
||||||
|
return ReadReceipt(originalMessageID: originalMessageID,
|
||||||
|
receiptID: receiptID,
|
||||||
|
readerID: readerID,
|
||||||
|
readerNickname: readerNickname,
|
||||||
|
timestamp: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||||
|
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||||
|
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||||
|
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||||
|
struct RequestSyncPacket {
|
||||||
|
let p: Int
|
||||||
|
let m: UInt32
|
||||||
|
let data: Data
|
||||||
|
|
||||||
|
func encode() -> Data {
|
||||||
|
var out = Data()
|
||||||
|
func putTLV(_ t: UInt8, _ v: Data) {
|
||||||
|
out.append(t)
|
||||||
|
let len = UInt16(v.count)
|
||||||
|
out.append(UInt8((len >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(len & 0xFF))
|
||||||
|
out.append(v)
|
||||||
|
}
|
||||||
|
// P
|
||||||
|
putTLV(0x01, Data([UInt8(p & 0xFF)]))
|
||||||
|
// M (uint32)
|
||||||
|
var mBE = m.bigEndian
|
||||||
|
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||||
|
// data
|
||||||
|
putTLV(0x03, data)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||||
|
var off = 0
|
||||||
|
var p: Int? = nil
|
||||||
|
var m: UInt32? = nil
|
||||||
|
var payload: Data? = nil
|
||||||
|
|
||||||
|
while off + 3 <= data.count {
|
||||||
|
let t = Int(data[off]); off += 1
|
||||||
|
guard off + 2 <= data.count else { return nil }
|
||||||
|
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
|
||||||
|
guard off + len <= data.count else { return nil }
|
||||||
|
let v = data.subdata(in: off..<(off+len)); off += len
|
||||||
|
switch t {
|
||||||
|
case 0x01:
|
||||||
|
if v.count == 1 { p = Int(v[0]) }
|
||||||
|
case 0x02:
|
||||||
|
if v.count == 4 {
|
||||||
|
var mm: UInt32 = 0
|
||||||
|
for b in v { mm = (mm << 8) | UInt32(b) }
|
||||||
|
m = mm
|
||||||
|
}
|
||||||
|
case 0x03:
|
||||||
|
if v.count > maxAcceptBytes { return nil }
|
||||||
|
payload = v
|
||||||
|
default:
|
||||||
|
break // forward compatible; ignore unknown TLVs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||||
|
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 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.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
@@ -85,6 +86,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
|
// Global network policy gate
|
||||||
|
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||||
Task.detached {
|
Task.detached {
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
@@ -116,6 +119,8 @@ 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
|
||||||
|
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if 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
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
@@ -138,6 +143,8 @@ 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
|
||||||
|
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if 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
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
@@ -212,6 +219,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
handler: @escaping (NostrEvent) -> Void,
|
handler: @escaping (NostrEvent) -> Void,
|
||||||
onEOSE: (() -> Void)? = nil
|
onEOSE: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
|
// Global network policy gate
|
||||||
|
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if messageHandlers[id] != nil {
|
if messageHandlers[id] != nil {
|
||||||
@@ -316,6 +325,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func connectToRelay(_ urlString: String) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
|
// Global network policy gate
|
||||||
|
if !TorManager.shared.isAutoStartAllowed() { 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
|
||||||
@@ -522,6 +533,13 @@ 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 !TorManager.shared.isAutoStartAllowed() {
|
||||||
|
connections.removeValue(forKey: relayUrl)
|
||||||
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
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)
|
||||||
|
|||||||
@@ -22,11 +22,12 @@
|
|||||||
///
|
///
|
||||||
/// ## Wire Format
|
/// ## Wire Format
|
||||||
/// ```
|
/// ```
|
||||||
/// Header (Fixed 13 bytes):
|
/// Header (13 bytes for v1, 15 bytes for v2):
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+---------------+
|
||||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes (v1) |
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// | | | | | | 4 bytes (v2) |
|
||||||
|
/// +--------+------+-----+-----------+-------+---------------+
|
||||||
///
|
///
|
||||||
/// Variable sections:
|
/// Variable sections:
|
||||||
/// +----------+-------------+---------+------------+
|
/// +----------+-------------+---------+------------+
|
||||||
@@ -56,9 +57,10 @@
|
|||||||
/// - Bits 3-7: Reserved for future use
|
/// - Bits 3-7: Reserved for future use
|
||||||
///
|
///
|
||||||
/// ## Size Constraints
|
/// ## Size Constraints
|
||||||
/// - Maximum packet size: 65,535 bytes (16-bit length field)
|
/// - Maximum packet size: 4.2GB (32-bit length field for v2+)
|
||||||
/// - Typical packet size: < 512 bytes (BLE MTU)
|
/// - Typical packet size: < 512 bytes (BLE MTU)
|
||||||
/// - Minimum packet size: 21 bytes (header + sender ID)
|
/// - Minimum packet size: 21 bytes (header + sender ID)
|
||||||
|
/// - Legacy v1 packets: max 65,535 bytes (16-bit length field)
|
||||||
///
|
///
|
||||||
/// ## Encoding Process
|
/// ## Encoding Process
|
||||||
/// 1. Construct header with fixed fields
|
/// 1. Construct header with fixed fields
|
||||||
@@ -105,16 +107,22 @@ extension Data {
|
|||||||
/// 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)
|
||||||
struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
static let headerSize = 13
|
static let headerSizeV1 = 13
|
||||||
|
static let headerSizeV2 = 15
|
||||||
static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
struct Flags {
|
struct Flags {
|
||||||
static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
static let isCompressed: UInt8 = 0x04
|
static let isCompressed: UInt8 = 0x04
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get header size based on protocol version
|
||||||
|
static func getHeaderSize(_ version: UInt8) -> Int {
|
||||||
|
return version >= 2 ? headerSizeV2 : headerSizeV1
|
||||||
|
}
|
||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||||
@@ -138,9 +146,10 @@ struct BinaryProtocol {
|
|||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
// Header with version-dependent size
|
||||||
|
let headerSize = getHeaderSize(packet.version)
|
||||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
// header + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||||
data.reserveCapacity(estimated)
|
data.reserveCapacity(estimated)
|
||||||
@@ -166,13 +175,20 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2)
|
||||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||||
let payloadLength = UInt16(payloadDataSize)
|
if packet.version >= 2 {
|
||||||
|
// v2+: 4 bytes big-endian
|
||||||
|
data.append(UInt8((payloadDataSize >> 24) & 0xFF))
|
||||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
data.append(UInt8((payloadDataSize >> 16) & 0xFF))
|
||||||
data.append(UInt8(payloadLength & 0xFF))
|
data.append(UInt8((payloadDataSize >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(payloadDataSize & 0xFF))
|
||||||
|
} else {
|
||||||
|
// v1: 2 bytes big-endian
|
||||||
|
let payloadLength16 = UInt16(payloadDataSize)
|
||||||
|
data.append(UInt8((payloadLength16 >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(payloadLength16 & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
// SenderID (exactly 8 bytes)
|
// SenderID (exactly 8 bytes)
|
||||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||||
@@ -227,8 +243,8 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
// Minimum size: header + senderID
|
// Minimum size: smallest header (v1) + senderID
|
||||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
guard raw.count >= headerSizeV1 + senderIDSize else { return nil }
|
||||||
|
|
||||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||||
guard let base = buf.baseAddress else { return nil }
|
guard let base = buf.baseAddress else { return nil }
|
||||||
@@ -249,6 +265,14 @@ struct BinaryProtocol {
|
|||||||
offset += 2
|
offset += 2
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
// Read big-endian 32-bit
|
||||||
|
func read32() -> UInt32? {
|
||||||
|
guard require(4) else { return nil }
|
||||||
|
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
|
let v = (UInt32(p[0]) << 24) | (UInt32(p[1]) << 16) | (UInt32(p[2]) << 8) | UInt32(p[3])
|
||||||
|
offset += 4
|
||||||
|
return v
|
||||||
|
}
|
||||||
// Copy N bytes into Data
|
// Copy N bytes into Data
|
||||||
func readData(_ n: Int) -> Data? {
|
func readData(_ n: Int) -> Data? {
|
||||||
guard require(n) else { return nil }
|
guard require(n) else { return nil }
|
||||||
@@ -258,8 +282,8 @@ struct BinaryProtocol {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version
|
// Version - accept both v1 and v2+
|
||||||
guard let version = read8(), version == 1 else { return nil }
|
guard let version = read8(), version >= 1 else { return nil }
|
||||||
guard let type = read8() else { return nil }
|
guard let type = read8() else { return nil }
|
||||||
guard let ttl = read8() else { return nil }
|
guard let ttl = read8() else { return nil }
|
||||||
|
|
||||||
@@ -277,8 +301,15 @@ struct BinaryProtocol {
|
|||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
|
|
||||||
// Payload length
|
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2+)
|
||||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
let payloadLen: UInt32
|
||||||
|
if version >= 2 {
|
||||||
|
guard let len32 = read32(), len32 <= 4_294_967_295 else { return nil }
|
||||||
|
payloadLen = len32
|
||||||
|
} else {
|
||||||
|
guard let len16 = read16(), len16 <= 65535 else { return nil }
|
||||||
|
payloadLen = UInt32(len16)
|
||||||
|
}
|
||||||
|
|
||||||
// SenderID
|
// SenderID
|
||||||
guard let senderID = readData(senderIDSize) else { return nil }
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
@@ -317,6 +348,7 @@ struct BinaryProtocol {
|
|||||||
guard offset <= buf.count else { return nil }
|
guard offset <= buf.count else { return nil }
|
||||||
|
|
||||||
return BitchatPacket(
|
return BitchatPacket(
|
||||||
|
version: version,
|
||||||
type: type,
|
type: type,
|
||||||
senderID: senderID,
|
senderID: senderID,
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
@@ -328,236 +360,3 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary encoding for BitchatMessage
|
|
||||||
extension BitchatMessage {
|
|
||||||
func toBinaryPayload() -> Data? {
|
|
||||||
var data = Data()
|
|
||||||
|
|
||||||
// Message format:
|
|
||||||
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
|
|
||||||
// - Timestamp: 8 bytes (seconds since epoch)
|
|
||||||
// - ID length: 1 byte
|
|
||||||
// - ID: variable
|
|
||||||
// - Sender length: 1 byte
|
|
||||||
// - Sender: variable
|
|
||||||
// - Content length: 2 bytes
|
|
||||||
// - Content: variable
|
|
||||||
// Optional fields based on flags:
|
|
||||||
// - Original sender length + data
|
|
||||||
// - Recipient nickname length + data
|
|
||||||
// - Sender peer ID length + data
|
|
||||||
// - Mentions array
|
|
||||||
|
|
||||||
var flags: UInt8 = 0
|
|
||||||
if isRelay { flags |= 0x01 }
|
|
||||||
if isPrivate { flags |= 0x02 }
|
|
||||||
if originalSender != nil { flags |= 0x04 }
|
|
||||||
if recipientNickname != nil { flags |= 0x08 }
|
|
||||||
if senderPeerID != nil { flags |= 0x10 }
|
|
||||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
|
||||||
|
|
||||||
data.append(flags)
|
|
||||||
|
|
||||||
// Timestamp (in milliseconds)
|
|
||||||
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
|
|
||||||
// Encode as 8 bytes, big-endian
|
|
||||||
for i in (0..<8).reversed() {
|
|
||||||
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID
|
|
||||||
if let idData = id.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(idData.count, 255)))
|
|
||||||
data.append(idData.prefix(255))
|
|
||||||
} else {
|
|
||||||
data.append(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sender
|
|
||||||
if let senderData = sender.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(senderData.count, 255)))
|
|
||||||
data.append(senderData.prefix(255))
|
|
||||||
} else {
|
|
||||||
data.append(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content
|
|
||||||
if let contentData = content.data(using: .utf8) {
|
|
||||||
let length = UInt16(min(contentData.count, 65535))
|
|
||||||
// Encode length as 2 bytes, big-endian
|
|
||||||
data.append(UInt8((length >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(length & 0xFF))
|
|
||||||
data.append(contentData.prefix(Int(length)))
|
|
||||||
} else {
|
|
||||||
data.append(contentsOf: [0, 0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(origData.count, 255)))
|
|
||||||
data.append(origData.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(recipData.count, 255)))
|
|
||||||
data.append(recipData.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(peerData.count, 255)))
|
|
||||||
data.append(peerData.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mentions array
|
|
||||||
if let mentions = mentions {
|
|
||||||
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
|
|
||||||
for mention in mentions.prefix(255) {
|
|
||||||
if let mentionData = mention.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(mentionData.count, 255)))
|
|
||||||
data.append(mentionData.prefix(255))
|
|
||||||
} else {
|
|
||||||
data.append(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
|
|
||||||
// Create an immutable copy to prevent threading issues
|
|
||||||
let dataCopy = Data(data)
|
|
||||||
|
|
||||||
|
|
||||||
guard dataCopy.count >= 13 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
|
|
||||||
// Flags
|
|
||||||
guard offset < dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let flags = dataCopy[offset]; offset += 1
|
|
||||||
let isRelay = (flags & 0x01) != 0
|
|
||||||
let isPrivate = (flags & 0x02) != 0
|
|
||||||
let hasOriginalSender = (flags & 0x04) != 0
|
|
||||||
let hasRecipientNickname = (flags & 0x08) != 0
|
|
||||||
let hasSenderPeerID = (flags & 0x10) != 0
|
|
||||||
let hasMentions = (flags & 0x20) != 0
|
|
||||||
|
|
||||||
// Timestamp
|
|
||||||
guard offset + 8 <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let timestampData = dataCopy[offset..<offset+8]
|
|
||||||
let timestampMillis = timestampData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt64(byte)
|
|
||||||
}
|
|
||||||
offset += 8
|
|
||||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
|
|
||||||
|
|
||||||
// ID
|
|
||||||
guard offset < dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let idLength = Int(dataCopy[offset]); offset += 1
|
|
||||||
guard offset + idLength <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
|
|
||||||
offset += idLength
|
|
||||||
|
|
||||||
// Sender
|
|
||||||
guard offset < dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let senderLength = Int(dataCopy[offset]); offset += 1
|
|
||||||
guard offset + senderLength <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
|
|
||||||
offset += senderLength
|
|
||||||
|
|
||||||
// Content
|
|
||||||
guard offset + 2 <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let contentLengthData = dataCopy[offset..<offset+2]
|
|
||||||
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt16(byte)
|
|
||||||
})
|
|
||||||
offset += 2
|
|
||||||
guard offset + contentLength <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
|
|
||||||
offset += contentLength
|
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
var originalSender: String?
|
|
||||||
if hasOriginalSender && offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var recipientNickname: String?
|
|
||||||
if hasRecipientNickname && offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var senderPeerID: String?
|
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mentions array
|
|
||||||
var mentions: [String]?
|
|
||||||
if hasMentions && offset < dataCopy.count {
|
|
||||||
let mentionCount = Int(dataCopy[offset]); offset += 1
|
|
||||||
if mentionCount > 0 {
|
|
||||||
mentions = []
|
|
||||||
for _ in 0..<mentionCount {
|
|
||||||
if offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
|
|
||||||
mentions?.append(mention)
|
|
||||||
}
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: id,
|
|
||||||
sender: sender,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: isRelay,
|
|
||||||
originalSender: originalSender,
|
|
||||||
isPrivate: isPrivate,
|
|
||||||
recipientNickname: recipientNickname,
|
|
||||||
senderPeerID: senderPeerID,
|
|
||||||
mentions: mentions
|
|
||||||
)
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,61 +59,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Message Padding
|
|
||||||
|
|
||||||
/// Provides privacy-preserving message padding to obscure actual content length.
|
|
||||||
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
|
||||||
struct MessagePadding {
|
|
||||||
// Standard block sizes for padding
|
|
||||||
static let blockSizes = [256, 512, 1024, 2048]
|
|
||||||
|
|
||||||
// Add PKCS#7-style padding to reach target size
|
|
||||||
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
|
|
||||||
guard data.count < targetSize else { return data }
|
|
||||||
|
|
||||||
let paddingNeeded = targetSize - data.count
|
|
||||||
// Constrain to 255 to fit a single-byte pad length marker
|
|
||||||
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
|
|
||||||
|
|
||||||
var padded = data
|
|
||||||
// PKCS#7: All pad bytes are equal to the pad length
|
|
||||||
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
|
|
||||||
return padded
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove padding from data
|
|
||||||
static func unpad(_ data: Data) -> Data {
|
|
||||||
guard !data.isEmpty else { return data }
|
|
||||||
let last = data.last!
|
|
||||||
let paddingLength = Int(last)
|
|
||||||
// Must have at least 1 pad byte and not exceed data length
|
|
||||||
guard paddingLength > 0 && paddingLength <= data.count else { return data }
|
|
||||||
// Verify PKCS#7: all last N bytes equal to pad length
|
|
||||||
let start = data.count - paddingLength
|
|
||||||
let tail = data[start...]
|
|
||||||
for b in tail { if b != last { return data } }
|
|
||||||
return Data(data[..<start])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find optimal block size for data
|
|
||||||
static func optimalBlockSize(for dataSize: Int) -> Int {
|
|
||||||
// Account for encryption overhead (~16 bytes for AES-GCM tag)
|
|
||||||
let totalSize = dataSize + 16
|
|
||||||
|
|
||||||
// Find smallest block that fits
|
|
||||||
for blockSize in blockSizes {
|
|
||||||
if totalSize <= blockSize {
|
|
||||||
return blockSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For very large messages, just use the original size
|
|
||||||
// (will be fragmented anyway)
|
|
||||||
return dataSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Types
|
// MARK: - Message Types
|
||||||
|
|
||||||
@@ -125,6 +70,7 @@ enum MessageType: UInt8 {
|
|||||||
case announce = 0x01 // "I'm here" with nickname
|
case announce = 0x01 // "I'm here" with nickname
|
||||||
case message = 0x02 // Public chat message
|
case message = 0x02 // Public chat message
|
||||||
case leave = 0x03 // "I'm leaving"
|
case leave = 0x03 // "I'm leaving"
|
||||||
|
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||||
|
|
||||||
// Noise encryption
|
// Noise encryption
|
||||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||||
@@ -132,15 +78,18 @@ enum MessageType: UInt8 {
|
|||||||
|
|
||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
|
case fileTransfer = 0x22 // File transfer (audio/images) TLV payload
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .announce: return "announce"
|
case .announce: return "announce"
|
||||||
case .message: return "message"
|
case .message: return "message"
|
||||||
case .leave: return "leave"
|
case .leave: return "leave"
|
||||||
|
case .requestSync: return "requestSync"
|
||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
|
case .fileTransfer: return "fileTransfer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,187 +130,6 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
case failed(Error) // Handshake failed
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
// MARK: - Core Protocol Structures
|
|
||||||
|
|
||||||
/// The core packet structure for all BitChat protocol messages.
|
|
||||||
/// Encapsulates all data needed for routing through the mesh network,
|
|
||||||
/// including TTL for hop limiting and optional encryption.
|
|
||||||
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
|
||||||
struct BitchatPacket: Codable {
|
|
||||||
let version: UInt8
|
|
||||||
let type: UInt8
|
|
||||||
let senderID: Data
|
|
||||||
let recipientID: Data?
|
|
||||||
let timestamp: UInt64
|
|
||||||
let payload: Data
|
|
||||||
var signature: Data?
|
|
||||||
var ttl: UInt8
|
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
|
||||||
self.version = 1
|
|
||||||
self.type = type
|
|
||||||
self.senderID = senderID
|
|
||||||
self.recipientID = recipientID
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.payload = payload
|
|
||||||
self.signature = signature
|
|
||||||
self.ttl = ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
|
||||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
|
||||||
self.version = 1
|
|
||||||
self.type = type
|
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
|
||||||
var senderData = Data()
|
|
||||||
var tempID = senderID
|
|
||||||
while tempID.count >= 2 {
|
|
||||||
let hexByte = String(tempID.prefix(2))
|
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
|
||||||
senderData.append(byte)
|
|
||||||
}
|
|
||||||
tempID = String(tempID.dropFirst(2))
|
|
||||||
}
|
|
||||||
self.senderID = senderData
|
|
||||||
self.recipientID = nil
|
|
||||||
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
|
|
||||||
self.payload = payload
|
|
||||||
self.signature = nil
|
|
||||||
self.ttl = ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
var data: Data? {
|
|
||||||
BinaryProtocol.encode(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBinaryData(padding: Bool = true) -> Data? {
|
|
||||||
BinaryProtocol.encode(self, padding: padding)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward-compatible helper (defaults to padded encoding)
|
|
||||||
func toBinaryData() -> Data? {
|
|
||||||
toBinaryData(padding: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create binary representation for signing (without signature and TTL fields)
|
|
||||||
/// TTL is excluded because it changes during packet relay operations
|
|
||||||
func toBinaryDataForSigning() -> Data? {
|
|
||||||
// Create a copy without signature and with fixed TTL for signing
|
|
||||||
// TTL must be excluded because it changes during relay
|
|
||||||
let unsignedPacket = BitchatPacket(
|
|
||||||
type: type,
|
|
||||||
senderID: senderID,
|
|
||||||
recipientID: recipientID,
|
|
||||||
timestamp: timestamp,
|
|
||||||
payload: payload,
|
|
||||||
signature: nil, // Remove signature for signing
|
|
||||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
|
||||||
)
|
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func from(_ data: Data) -> BitchatPacket? {
|
|
||||||
BinaryProtocol.decode(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
// MARK: - Read Receipts
|
|
||||||
|
|
||||||
// Read receipt structure
|
|
||||||
struct ReadReceipt: Codable {
|
|
||||||
let originalMessageID: String
|
|
||||||
let receiptID: String
|
|
||||||
var readerID: String // Who read it
|
|
||||||
let readerNickname: String
|
|
||||||
let timestamp: Date
|
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
|
||||||
self.originalMessageID = originalMessageID
|
|
||||||
self.receiptID = UUID().uuidString
|
|
||||||
self.readerID = readerID
|
|
||||||
self.readerNickname = readerNickname
|
|
||||||
self.timestamp = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// For binary decoding
|
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
|
||||||
self.originalMessageID = originalMessageID
|
|
||||||
self.receiptID = receiptID
|
|
||||||
self.readerID = readerID
|
|
||||||
self.readerNickname = readerNickname
|
|
||||||
self.timestamp = timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode() -> Data? {
|
|
||||||
try? JSONEncoder().encode(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(from data: Data) -> ReadReceipt? {
|
|
||||||
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Binary Encoding
|
|
||||||
|
|
||||||
func toBinaryData() -> Data {
|
|
||||||
var data = Data()
|
|
||||||
data.appendUUID(originalMessageID)
|
|
||||||
data.appendUUID(receiptID)
|
|
||||||
// ReaderID as 8-byte hex string
|
|
||||||
var readerData = Data()
|
|
||||||
var tempID = readerID
|
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
|
||||||
let hexByte = String(tempID.prefix(2))
|
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
|
||||||
readerData.append(byte)
|
|
||||||
}
|
|
||||||
tempID = String(tempID.dropFirst(2))
|
|
||||||
}
|
|
||||||
while readerData.count < 8 {
|
|
||||||
readerData.append(0)
|
|
||||||
}
|
|
||||||
data.append(readerData)
|
|
||||||
data.appendDate(timestamp)
|
|
||||||
data.appendString(readerNickname)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
|
||||||
// Create defensive copy
|
|
||||||
let dataCopy = Data(data)
|
|
||||||
|
|
||||||
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
|
||||||
guard dataCopy.count >= 49 else { return nil }
|
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
|
|
||||||
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
|
||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
|
||||||
let readerID = readerIDData.hexEncodedString()
|
|
||||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
|
||||||
InputValidator.validateTimestamp(timestamp),
|
|
||||||
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
|
||||||
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
|
||||||
|
|
||||||
return ReadReceipt(originalMessageID: originalMessageID,
|
|
||||||
receiptID: receiptID,
|
|
||||||
readerID: readerID,
|
|
||||||
readerNickname: readerNickname,
|
|
||||||
timestamp: timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Delivery Status
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
// Delivery status for messages
|
// Delivery status for messages
|
||||||
@@ -391,74 +159,6 @@ enum DeliveryStatus: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Model
|
|
||||||
|
|
||||||
/// Represents a user-visible message in the BitChat system.
|
|
||||||
/// Handles both broadcast messages and private encrypted messages,
|
|
||||||
/// with support for mentions, replies, and delivery tracking.
|
|
||||||
/// - Note: This is the primary data model for chat messages
|
|
||||||
final class BitchatMessage: Codable {
|
|
||||||
let id: String
|
|
||||||
let sender: String
|
|
||||||
let content: String
|
|
||||||
let timestamp: Date
|
|
||||||
let isRelay: Bool
|
|
||||||
let originalSender: String?
|
|
||||||
let isPrivate: Bool
|
|
||||||
let recipientNickname: String?
|
|
||||||
let senderPeerID: String?
|
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
|
||||||
|
|
||||||
// Cached formatted text (not included in Codable)
|
|
||||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
|
||||||
|
|
||||||
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
|
||||||
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
|
||||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codable implementation
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case id, sender, content, timestamp, isRelay, originalSender
|
|
||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
|
||||||
self.id = id ?? UUID().uuidString
|
|
||||||
self.sender = sender
|
|
||||||
self.content = content
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.isRelay = isRelay
|
|
||||||
self.originalSender = originalSender
|
|
||||||
self.isPrivate = isPrivate
|
|
||||||
self.recipientNickname = recipientNickname
|
|
||||||
self.senderPeerID = senderPeerID
|
|
||||||
self.mentions = mentions
|
|
||||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equatable conformance for BitchatMessage
|
|
||||||
extension BitchatMessage: Equatable {
|
|
||||||
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
|
||||||
return lhs.id == rhs.id &&
|
|
||||||
lhs.sender == rhs.sender &&
|
|
||||||
lhs.content == rhs.content &&
|
|
||||||
lhs.timestamp == rhs.timestamp &&
|
|
||||||
lhs.isRelay == rhs.isRelay &&
|
|
||||||
lhs.originalSender == rhs.originalSender &&
|
|
||||||
lhs.isPrivate == rhs.isPrivate &&
|
|
||||||
lhs.recipientNickname == rhs.recipientNickname &&
|
|
||||||
lhs.senderPeerID == rhs.senderPeerID &&
|
|
||||||
lhs.mentions == rhs.mentions &&
|
|
||||||
lhs.deliveryStatus == rhs.deliveryStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
@@ -495,37 +195,3 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Noise Payload Helpers
|
|
||||||
|
|
||||||
/// Helper to create typed Noise payloads
|
|
||||||
struct NoisePayload {
|
|
||||||
let type: NoisePayloadType
|
|
||||||
let data: Data
|
|
||||||
|
|
||||||
/// Encode payload with type prefix
|
|
||||||
func encode() -> Data {
|
|
||||||
var encoded = Data()
|
|
||||||
encoded.append(type.rawValue)
|
|
||||||
encoded.append(data)
|
|
||||||
return encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode payload from data
|
|
||||||
static func decode(_ data: Data) -> NoisePayload? {
|
|
||||||
// Ensure we have at least 1 byte for the type
|
|
||||||
guard !data.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely get the first byte
|
|
||||||
let firstByte = data[data.startIndex]
|
|
||||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a proper Data copy (not a subsequence) for thread safety
|
|
||||||
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
|
||||||
return NoisePayload(type: type, data: payloadData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Combine
|
import Combine
|
||||||
@@ -15,7 +16,7 @@ final class BLEService: NSObject {
|
|||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet
|
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // testnet
|
||||||
#else
|
#else
|
||||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
||||||
#endif
|
#endif
|
||||||
@@ -138,6 +139,9 @@ final class BLEService: NSObject {
|
|||||||
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
||||||
// Debounce for 'reconnected' logs
|
// Debounce for 'reconnected' logs
|
||||||
private var lastReconnectLogAt: [String: Date] = [:]
|
private var lastReconnectLogAt: [String: Date] = [:]
|
||||||
|
|
||||||
|
// MARK: - Gossip Sync
|
||||||
|
private var gossipSyncManager: GossipSyncManager?
|
||||||
|
|
||||||
// MARK: - Maintenance Timer
|
// MARK: - Maintenance Timer
|
||||||
|
|
||||||
@@ -398,9 +402,15 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
maintenanceTimer = timer
|
maintenanceTimer = timer
|
||||||
|
|
||||||
// Publish initial empty state
|
// Publish initial empty state
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
|
|
||||||
|
// Initialize gossip sync manager
|
||||||
|
let sync = GossipSyncManager(myPeerID: myPeerID)
|
||||||
|
sync.delegate = self
|
||||||
|
sync.start()
|
||||||
|
self.gossipSyncManager = sync
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNickname(_ nickname: String) {
|
func setNickname(_ nickname: String) {
|
||||||
@@ -775,6 +785,8 @@ final class BLEService: NSObject {
|
|||||||
self.messageDeduplicator.markProcessed(dedupID)
|
self.messageDeduplicator.markProcessed(dedupID)
|
||||||
// Call synchronously since we're already on background queue
|
// Call synchronously since we're already on background queue
|
||||||
self.broadcastPacket(signedPacket)
|
self.broadcastPacket(signedPacket)
|
||||||
|
// Track our own broadcast for sync
|
||||||
|
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1099,10 +1111,13 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
||||||
// Special-case announces: do NOT subset to maximize reach for presence
|
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
|
||||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||||
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
|
if directedOnlyPeer == nil
|
||||||
|
&& packet.type != MessageType.fragment.rawValue
|
||||||
|
&& packet.type != MessageType.announce.rawValue
|
||||||
|
&& packet.type != MessageType.requestSync.rawValue {
|
||||||
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
||||||
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
||||||
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
||||||
@@ -1133,6 +1148,12 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||||||
|
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: String) {
|
||||||
|
guard let data = packet.toBinaryData(padding: false) else { return }
|
||||||
|
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Directed store-and-forward
|
// MARK: - Directed store-and-forward
|
||||||
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
|
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
|
||||||
let msgID = makeMessageID(for: packet)
|
let msgID = makeMessageID(for: packet)
|
||||||
@@ -1255,6 +1276,127 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File transfer with progress: send TLV payload inside a FILE_TRANSFER packet.
|
||||||
|
// Reports .partiallyDelivered via delegate for the provided messageID during fragmentation.
|
||||||
|
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
|
||||||
|
// Build packet
|
||||||
|
let recipientData: Data? = recipientPeerID.flatMap { Data(hexString: $0) }
|
||||||
|
var packet = BitchatPacket(
|
||||||
|
version: 2, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
|
||||||
|
type: MessageType.fileTransfer.rawValue,
|
||||||
|
senderID: myPeerIDData,
|
||||||
|
recipientID: recipientData,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
// Log debug info about the file TLV
|
||||||
|
if let fp = BitchatFilePacket.decode(from: payload) {
|
||||||
|
let sha = fp.content.sha256Hex()
|
||||||
|
let scope = (recipientData == nil || recipientData == Data(repeating: 0xFF, count: 8)) ? "broadcast" : "private"
|
||||||
|
SecureLogger.debug("📤 FILE_TRANSFER send (\(scope)) name=\(fp.fileName) mime=\(fp.mimeType) size=\(fp.fileSize) sha256=\(sha)", category: .session)
|
||||||
|
} else {
|
||||||
|
SecureLogger.warning("⚠️ FILE_TRANSFER send: failed to decode TLV for logging (payload=\(payload.count) bytes)", category: .session)
|
||||||
|
}
|
||||||
|
// For private sends, sign (integrity)
|
||||||
|
if recipientData != nil {
|
||||||
|
if let signed = noiseService.signPacket(packet) { packet = signed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode once to decide if fragmentation is needed on current links
|
||||||
|
let pad = padPolicy(for: packet.type)
|
||||||
|
guard let data = packet.toBinaryData(padding: pad) else { return }
|
||||||
|
|
||||||
|
// Determine max link MTU across active links similar to sendOnAllLinks
|
||||||
|
let states = snapshotPeripheralStates()
|
||||||
|
var minCentralWriteLen: Int?
|
||||||
|
for s in states where s.isConnected {
|
||||||
|
let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)
|
||||||
|
minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m
|
||||||
|
}
|
||||||
|
var minNotifyLen: Int?
|
||||||
|
do {
|
||||||
|
let (centrals, _) = snapshotSubscribedCentrals()
|
||||||
|
if !centrals.isEmpty {
|
||||||
|
minNotifyLen = centrals.map { $0.maximumUpdateValueLength }.min()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let needsFragment = {
|
||||||
|
if let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min() {
|
||||||
|
return data.count > minLen
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
if needsFragment {
|
||||||
|
let overhead = 13 + 8 + 8 + 13
|
||||||
|
let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min() ?? defaultFragmentSize
|
||||||
|
let chunk = max(64, minLen - overhead)
|
||||||
|
|
||||||
|
// Create fragments and emit progress per fragment
|
||||||
|
guard let fullData = packet.toBinaryData(padding: pad) else { return }
|
||||||
|
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||||
|
let fragments = stride(from: 0, to: fullData.count, by: chunk).map { offset in
|
||||||
|
Data(fullData[offset..<min(offset + chunk, fullData.count)])
|
||||||
|
}
|
||||||
|
let total = fragments.count
|
||||||
|
|
||||||
|
// Pause scanning for long trains as in sendFragmentedPacket
|
||||||
|
if total > 4 {
|
||||||
|
bleQueue.async { [weak self] in
|
||||||
|
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
|
||||||
|
if c.isScanning { c.stopScan() }
|
||||||
|
let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, total * TransportConfig.bleExpectedWritePerFragmentMs)
|
||||||
|
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self?.startScanning() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, fragment) in fragments.enumerated() {
|
||||||
|
var fragPayload = Data()
|
||||||
|
fragPayload.append(fragmentID)
|
||||||
|
fragPayload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
|
||||||
|
fragPayload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
|
||||||
|
fragPayload.append(packet.type)
|
||||||
|
fragPayload.append(fragment)
|
||||||
|
|
||||||
|
let fragmentRecipient: Data? = recipientData
|
||||||
|
let fragmentPacket = BitchatPacket(
|
||||||
|
type: MessageType.fragment.rawValue,
|
||||||
|
senderID: packet.senderID,
|
||||||
|
recipientID: fragmentRecipient,
|
||||||
|
timestamp: packet.timestamp,
|
||||||
|
payload: fragPayload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: packet.ttl
|
||||||
|
)
|
||||||
|
let perFragMs = (recipientData != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
|
||||||
|
let delayMs = index * perFragMs
|
||||||
|
|
||||||
|
// Emit progress to UI just before scheduling send
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: index, total: total))
|
||||||
|
}
|
||||||
|
|
||||||
|
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||||
|
self?.broadcastPacket(fragmentPacket)
|
||||||
|
if index == total - 1 {
|
||||||
|
self?.notifyUI { [weak self] in
|
||||||
|
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single packet path
|
||||||
|
broadcastPacket(packet)
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleFragment(_ packet: BitchatPacket, from peerID: String) {
|
private func handleFragment(_ packet: BitchatPacket, from peerID: String) {
|
||||||
// Don't process our own fragments
|
// Don't process our own fragments
|
||||||
@@ -1339,7 +1481,10 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Efficient deduplication
|
// Efficient deduplication
|
||||||
// Important: do not dedup fragment packets globally (each piece must pass)
|
// Important: do not dedup fragment packets globally (each piece must pass)
|
||||||
if packet.type != MessageType.fragment.rawValue && messageDeduplicator.isDuplicate(messageID) {
|
// Special case: allow our own packets recovered via sync (TTL==0) to pass
|
||||||
|
// through even if we've marked them as seen at send time.
|
||||||
|
let allowSelfSyncReplay = (packet.ttl == 0) && (senderID == myPeerID)
|
||||||
|
if packet.type != MessageType.fragment.rawValue && !allowSelfSyncReplay && messageDeduplicator.isDuplicate(messageID) {
|
||||||
// Announce packets (type 1) are sent every 10 seconds for peer discovery
|
// Announce packets (type 1) are sent every 10 seconds for peer discovery
|
||||||
// It's normal to see these as duplicates - don't log them to reduce noise
|
// It's normal to see these as duplicates - don't log them to reduce noise
|
||||||
if packet.type != MessageType.announce.rawValue {
|
if packet.type != MessageType.announce.rawValue {
|
||||||
@@ -1383,6 +1528,9 @@ final class BLEService: NSObject {
|
|||||||
case .message:
|
case .message:
|
||||||
handleMessage(packet, from: senderID)
|
handleMessage(packet, from: senderID)
|
||||||
|
|
||||||
|
case .requestSync:
|
||||||
|
handleRequestSync(packet, from: senderID)
|
||||||
|
|
||||||
case .noiseHandshake:
|
case .noiseHandshake:
|
||||||
handleNoiseHandshake(packet, from: senderID)
|
handleNoiseHandshake(packet, from: senderID)
|
||||||
|
|
||||||
@@ -1391,6 +1539,9 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
case .fragment:
|
case .fragment:
|
||||||
handleFragment(packet, from: senderID)
|
handleFragment(packet, from: senderID)
|
||||||
|
|
||||||
|
case .fileTransfer:
|
||||||
|
handleFileTransfer(packet, from: senderID)
|
||||||
|
|
||||||
case .leave:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
handleLeave(packet, from: senderID)
|
||||||
@@ -1433,6 +1584,72 @@ final class BLEService: NSObject {
|
|||||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
|
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: String) {
|
||||||
|
// Decode TLV
|
||||||
|
guard let file = BitchatFilePacket.decode(from: packet.payload) else {
|
||||||
|
SecureLogger.error("❌ Failed to decode file TLV from \(peerID)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let sha = file.content.sha256Hex()
|
||||||
|
let scope = (packet.recipientID == nil || packet.recipientID == Data(repeating: 0xFF, count: 8)) ? "broadcast" : "private"
|
||||||
|
SecureLogger.debug("📥 FILE_TRANSFER recv (\(scope)) from=\(peerID.prefix(8))… name=\(file.fileName) mime=\(file.mimeType) size=\(file.fileSize) sha256=\(sha)", category: .session)
|
||||||
|
// Determine if this is a true direct message; Android may use 0xFF..FF to denote broadcast
|
||||||
|
let isBroadcastRecipient: Bool = {
|
||||||
|
guard let rid = packet.recipientID else { return true }
|
||||||
|
return rid == Data(repeating: 0xFF, count: 8)
|
||||||
|
}()
|
||||||
|
// Persist to app files in type-specific folder
|
||||||
|
let isAudio = file.mimeType.lowercased().hasPrefix("audio/")
|
||||||
|
let subfolder = isAudio ? "voicenotes/incoming" : "images/incoming"
|
||||||
|
let ext: String = {
|
||||||
|
if let inferred = file.fileName.split(separator: ".").last { return String(inferred) }
|
||||||
|
if isAudio { return "m4a" }
|
||||||
|
return "bin"
|
||||||
|
}()
|
||||||
|
let unique = "\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(8)).\(ext)"
|
||||||
|
guard let savedURL = saveToAppFiles(subpath: subfolder, fileName: unique, data: file.content) else { return }
|
||||||
|
|
||||||
|
// Build synthetic message
|
||||||
|
let nickname = peerNickname(peerID: peerID) ?? "user"
|
||||||
|
let ts = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000.0)
|
||||||
|
let marker = isAudio ? "[voice]" : "[image]"
|
||||||
|
let content = "\(marker) \(savedURL.path)"
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
sender: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: ts,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: (packet.recipientID != nil) && !isBroadcastRecipient,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: nil
|
||||||
|
)
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.delegate?.didReceiveMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveToAppFiles(subpath: String, fileName: String, data: Data) -> URL? {
|
||||||
|
do {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let folder = base.appendingPathComponent("bitchat_\(subpath)", isDirectory: true)
|
||||||
|
if !fm.fileExists(atPath: folder.path) {
|
||||||
|
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
let url = folder.appendingPathComponent(fileName)
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return url
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to save incoming file: \(error)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) {
|
private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) {
|
||||||
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||||
@@ -1583,12 +1800,17 @@ final class BLEService: NSObject {
|
|||||||
// Only notify of connection for new or reconnected peers when it is a direct announce
|
// Only notify of connection for new or reconnected peers when it is a direct announce
|
||||||
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
||||||
self.delegate?.didConnectToPeer(peerID)
|
self.delegate?.didConnectToPeer(peerID)
|
||||||
|
// Schedule initial unicast sync to this peer
|
||||||
|
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.requestPeerDataPublish()
|
self.requestPeerDataPublish()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track for sync (include our own and others' announces)
|
||||||
|
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||||
|
|
||||||
// Send announce back for bidirectional discovery (only once per peer)
|
// Send announce back for bidirectional discovery (only once per peer)
|
||||||
let announceBackID = "announce-back-\(peerID)"
|
let announceBackID = "announce-back-\(peerID)"
|
||||||
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
|
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
|
||||||
@@ -1610,17 +1832,33 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||||||
|
private func handleRequestSync(_ packet: BitchatPacket, from peerID: String) {
|
||||||
|
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
|
||||||
|
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gossipSyncManager?.handleRequestSync(fromPeerID: peerID, request: req)
|
||||||
|
}
|
||||||
|
|
||||||
// Mention parsing moved to ChatViewModel
|
// Mention parsing moved to ChatViewModel
|
||||||
|
|
||||||
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
|
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
|
||||||
// Ignore self-origin public messages that may be seen again via relay
|
// Ignore self-origin public messages except when returned via sync (TTL==0).
|
||||||
if peerID == myPeerID { return }
|
// This allows our own messages to be surfaced when they come back via
|
||||||
|
// the sync path without re-processing regular relayed copies.
|
||||||
|
if peerID == myPeerID && packet.ttl != 0 { return }
|
||||||
|
|
||||||
var accepted = false
|
var accepted = false
|
||||||
var senderNickname: String = ""
|
var senderNickname: String = ""
|
||||||
|
|
||||||
if let info = peers[peerID], info.isVerifiedNickname {
|
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
|
||||||
|
if peerID == myPeerID {
|
||||||
|
accepted = true
|
||||||
|
senderNickname = myNickname
|
||||||
|
}
|
||||||
|
else if let info = peers[peerID], info.isVerifiedNickname {
|
||||||
// Known verified peer path
|
// Known verified peer path
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = info.nickname
|
senderNickname = info.nickname
|
||||||
@@ -1648,6 +1886,22 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If still not accepted and this is a sync-returned packet (TTL==0),
|
||||||
|
// accept with a generic nickname so history can be restored even for
|
||||||
|
// peers we haven't verified yet.
|
||||||
|
if !accepted && packet.ttl == 0 {
|
||||||
|
accepted = true
|
||||||
|
senderNickname = "anon" + String(peerID.prefix(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast)
|
||||||
|
let isBroadcastRecipient: Bool = {
|
||||||
|
guard let r = packet.recipientID else { return true }
|
||||||
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
|
}()
|
||||||
|
if isBroadcastRecipient && packet.type == MessageType.message.rawValue {
|
||||||
|
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard accepted else {
|
guard accepted else {
|
||||||
@@ -1780,6 +2034,8 @@ final class BLEService: NSObject {
|
|||||||
// Remove the peer when they leave
|
// Remove the peer when they leave
|
||||||
peers.removeValue(forKey: peerID)
|
peers.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
|
// Remove any stored announcement for sync purposes
|
||||||
|
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||||
// Send on main thread
|
// Send on main thread
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -1861,6 +2117,8 @@ final class BLEService: NSObject {
|
|||||||
self?.broadcastPacket(signedPacket)
|
self?.broadcastPacket(signedPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ensure our own announce is included in sync state
|
||||||
|
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||||
@@ -2088,6 +2346,8 @@ final class BLEService: NSObject {
|
|||||||
if !peer.isConnected {
|
if !peer.isConnected {
|
||||||
if age > retention {
|
if age > retention {
|
||||||
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
|
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
|
||||||
|
// Also remove any stored announcement from sync candidates
|
||||||
|
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||||
peers.removeValue(forKey: peerID)
|
peers.removeValue(forKey: peerID)
|
||||||
removedOfflineCount += 1
|
removedOfflineCount += 1
|
||||||
}
|
}
|
||||||
@@ -2249,6 +2509,29 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - GossipSyncManager Delegate
|
||||||
|
extension BLEService: GossipSyncManager.Delegate {
|
||||||
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
broadcastPacket(packet)
|
||||||
|
} else {
|
||||||
|
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPacket(to peerID: String, packet: BitchatPacket) {
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
sendPacketDirected(packet, to: peerID)
|
||||||
|
} else {
|
||||||
|
messageQueue.async { [weak self] in self?.sendPacketDirected(packet, to: peerID) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
|
return noiseService.signPacket(packet) ?? packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - CBCentralManagerDelegate
|
// MARK: - CBCentralManagerDelegate
|
||||||
|
|
||||||
extension BLEService: CBCentralManagerDelegate {
|
extension BLEService: CBCentralManagerDelegate {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
@@ -23,8 +24,8 @@ protocol KeychainManagerProtocol {
|
|||||||
|
|
||||||
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 = "chat.bitchat"
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.chat.bitchat"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
private func isSandboxed() -> Bool {
|
private func isSandboxed() -> Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Routes messages between BLE and Nostr transports
|
/// Routes messages between BLE and Nostr transports
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
|
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||||
|
@MainActor
|
||||||
|
final class NetworkActivationService: ObservableObject {
|
||||||
|
static let shared = NetworkActivationService()
|
||||||
|
|
||||||
|
@Published private(set) var activationAllowed: Bool = false
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
|
||||||
|
// Initial compute
|
||||||
|
activationAllowed = Self.computeAllowed()
|
||||||
|
TorManager.shared.setAutoStartAllowed(activationAllowed)
|
||||||
|
|
||||||
|
// React to location permission changes
|
||||||
|
LocationChannelManager.shared.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.reevaluate()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// React to mutual favorites changes
|
||||||
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.reevaluate()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reevaluate() {
|
||||||
|
let allowed = Self.computeAllowed()
|
||||||
|
if allowed != activationAllowed {
|
||||||
|
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
|
||||||
|
activationAllowed = allowed
|
||||||
|
TorManager.shared.setAutoStartAllowed(allowed)
|
||||||
|
if allowed {
|
||||||
|
// Kick Tor + relays if we're now permitted
|
||||||
|
TorManager.shared.startIfNeeded()
|
||||||
|
// If app is in foreground, begin relay connections
|
||||||
|
if TorManager.shared.isForeground() {
|
||||||
|
NostrRelayManager.shared.connect()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Transitioned to disallowed: disconnect relays and shut down Tor
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
TorManager.shared.goDormantOnBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func computeAllowed() -> Bool {
|
||||||
|
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||||
|
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
|
return permOK || hasMutual
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
/// - Background queue for CPU-intensive operations
|
/// - Background queue for CPU-intensive operations
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import BitLogger
|
||||||
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 {
|
final class NostrTransport: Transport {
|
||||||
|
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Darwin
|
import Darwin
|
||||||
@@ -68,18 +69,24 @@ final class TorManager: ObservableObject {
|
|||||||
private var pathMonitor: NWPathMonitor?
|
private var pathMonitor: NWPathMonitor?
|
||||||
private var isAppForeground: Bool = true
|
private var isAppForeground: Bool = true
|
||||||
private var lastRestartAt: Date? = nil
|
private var lastRestartAt: Date? = nil
|
||||||
|
// Global policy gate: only allow Tor to start when true
|
||||||
|
private(set) var allowAutoStart: Bool = false
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
func startIfNeeded() {
|
func startIfNeeded() {
|
||||||
|
// Respect global start policy
|
||||||
|
guard allowAutoStart else { return }
|
||||||
// Do not start in background; caller should wait for foreground
|
// Do not start in background; caller should wait for foreground
|
||||||
guard isAppForeground else { return }
|
guard isAppForeground else { return }
|
||||||
guard !didStart else { return }
|
guard !didStart else { return }
|
||||||
didStart = true
|
didStart = true
|
||||||
isStarting = true
|
isStarting = true
|
||||||
lastError = nil
|
lastError = nil
|
||||||
|
// Announce initial start so UI can show a status message
|
||||||
|
NotificationCenter.default.post(name: .TorWillStart, object: nil)
|
||||||
ensureFilesystemLayout()
|
ensureFilesystemLayout()
|
||||||
startTor()
|
startTor()
|
||||||
startPathMonitorIfNeeded()
|
startPathMonitorIfNeeded()
|
||||||
@@ -472,6 +479,8 @@ final class TorManager: ObservableObject {
|
|||||||
// MARK: - Foreground recovery and control helpers
|
// MARK: - Foreground recovery and control helpers
|
||||||
|
|
||||||
func ensureRunningOnForeground() {
|
func ensureRunningOnForeground() {
|
||||||
|
// Respect global start policy
|
||||||
|
if !allowAutoStart { return }
|
||||||
// iOS can suspend Tor harshly; the most reliable approach for
|
// iOS can suspend Tor harshly; the most reliable approach for
|
||||||
// embedding is to restart Tor every time we become active.
|
// embedding is to restart Tor every time we become active.
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
@@ -635,3 +644,14 @@ final class TorManager: ObservableObject {
|
|||||||
return resultText
|
return resultText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Start policy configuration
|
||||||
|
extension TorManager {
|
||||||
|
@MainActor
|
||||||
|
func setAutoStartAllowed(_ allow: Bool) {
|
||||||
|
allowAutoStart = allow
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func isAutoStartAllowed() -> Bool { allowAutoStart }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ import Foundation
|
|||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
||||||
static let TorWillRestart = Notification.Name("TorWillRestart")
|
static let TorWillRestart = Notification.Name("TorWillRestart")
|
||||||
|
static let TorWillStart = Notification.Name("TorWillStart")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ protocol Transport: AnyObject {
|
|||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||||
|
|
||||||
|
// Media transfer (file transfer TLV)
|
||||||
|
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String)
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// Golomb-Coded Set (GCS) filter utilities for sync.
|
||||||
|
// Hashing:
|
||||||
|
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
|
||||||
|
// - Map to [0, M) via (h64 % M).
|
||||||
|
// Encoding (v1):
|
||||||
|
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
|
||||||
|
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
|
||||||
|
// - Bitstream is MSB-first within each byte.
|
||||||
|
enum GCSFilter {
|
||||||
|
struct Params { let p: Int; let m: UInt32; let data: Data }
|
||||||
|
|
||||||
|
// Derive P from FPR (~ 1 / 2^P)
|
||||||
|
static func deriveP(targetFpr: Double) -> Int {
|
||||||
|
let f = max(0.000001, min(0.25, targetFpr))
|
||||||
|
// ceil(log2(1/f))
|
||||||
|
let p = Int(ceil(log2(1.0 / f)))
|
||||||
|
return max(1, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate max elements that fit in size bytes: bits per element ~= P + 2 (approx)
|
||||||
|
static func estimateMaxElements(sizeBytes: Int, p: Int) -> Int {
|
||||||
|
let bits = max(8, sizeBytes * 8)
|
||||||
|
let per = max(3, p + 2)
|
||||||
|
return max(1, bits / per)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||||
|
let p = deriveP(targetFpr: targetFpr)
|
||||||
|
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
||||||
|
let n = min(ids.count, cap)
|
||||||
|
let selected = Array(ids.prefix(n))
|
||||||
|
// Map to [0, M)
|
||||||
|
let mInit = UInt32(n << p)
|
||||||
|
var mapped = selected.map { id16 -> UInt64 in
|
||||||
|
let h = h64(id16)
|
||||||
|
return UInt64(h % UInt64(max(1, mInit)))
|
||||||
|
}.sorted()
|
||||||
|
var encoded = encode(sorted: mapped, p: p)
|
||||||
|
var trimmedN = n
|
||||||
|
// Trim if over budget
|
||||||
|
while encoded.count > maxBytes && trimmedN > 0 {
|
||||||
|
trimmedN = (trimmedN * 9) / 10 // drop ~10%
|
||||||
|
mapped = Array(mapped.prefix(trimmedN))
|
||||||
|
encoded = encode(sorted: mapped, p: p)
|
||||||
|
}
|
||||||
|
let finalM = UInt32(max(1, trimmedN << p))
|
||||||
|
return Params(p: p, m: finalM, data: encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||||
|
var values: [UInt64] = []
|
||||||
|
let reader = BitReader(data)
|
||||||
|
var acc: UInt64 = 0
|
||||||
|
while true {
|
||||||
|
guard let q = reader.readUnary() else { break }
|
||||||
|
guard let r = reader.readBits(count: p) else { break }
|
||||||
|
let x = (UInt64(q) << UInt64(p)) + UInt64(r) + 1
|
||||||
|
acc &+= x
|
||||||
|
if acc >= UInt64(m) { break }
|
||||||
|
values.append(acc)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
static func contains(sortedValues: [UInt64], candidate: UInt64) -> Bool {
|
||||||
|
var lo = 0
|
||||||
|
var hi = sortedValues.count - 1
|
||||||
|
while lo <= hi {
|
||||||
|
let mid = (lo + hi) >> 1
|
||||||
|
let v = sortedValues[mid]
|
||||||
|
if v == candidate { return true }
|
||||||
|
if v < candidate { lo = mid + 1 } else { hi = mid - 1 }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func h64(_ id16: Data) -> UInt64 {
|
||||||
|
var hasher = SHA256()
|
||||||
|
hasher.update(data: id16)
|
||||||
|
let d = hasher.finalize()
|
||||||
|
let db = Data(d)
|
||||||
|
var x: UInt64 = 0
|
||||||
|
let take = min(8, db.count)
|
||||||
|
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
|
||||||
|
return x & 0x7fff_ffff_ffff_ffff
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encode(sorted: [UInt64], p: Int) -> Data {
|
||||||
|
let writer = BitWriter()
|
||||||
|
var prev: UInt64 = 0
|
||||||
|
let mask: UInt64 = (p >= 64) ? ~0 : ((1 << UInt64(p)) - 1)
|
||||||
|
for v in sorted {
|
||||||
|
let delta = v &- prev
|
||||||
|
prev = v
|
||||||
|
let x = delta
|
||||||
|
let q = (x &- 1) >> UInt64(p)
|
||||||
|
let r = (x &- 1) & mask
|
||||||
|
// unary q ones then zero
|
||||||
|
if q > 0 { writer.writeOnes(count: Int(q)) }
|
||||||
|
writer.writeBit(0)
|
||||||
|
writer.writeBits(value: r, count: p)
|
||||||
|
}
|
||||||
|
return writer.toData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bit helpers (MSB-first)
|
||||||
|
private final class BitWriter {
|
||||||
|
private var buf = Data()
|
||||||
|
private var cur: UInt8 = 0
|
||||||
|
private var nbits: Int = 0
|
||||||
|
func writeBit(_ bit: Int) { // 0 or 1
|
||||||
|
cur = UInt8((Int(cur) << 1) | (bit & 1))
|
||||||
|
nbits += 1
|
||||||
|
if nbits == 8 {
|
||||||
|
buf.append(cur)
|
||||||
|
cur = 0; nbits = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func writeOnes(count: Int) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
for _ in 0..<count { writeBit(1) }
|
||||||
|
}
|
||||||
|
func writeBits(value: UInt64, count: Int) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
for i in stride(from: count - 1, through: 0, by: -1) {
|
||||||
|
let bit = Int((value >> UInt64(i)) & 1)
|
||||||
|
writeBit(bit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func toData() -> Data {
|
||||||
|
if nbits > 0 {
|
||||||
|
let rem = UInt8(Int(cur) << (8 - nbits))
|
||||||
|
buf.append(rem)
|
||||||
|
cur = 0; nbits = 0
|
||||||
|
}
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class BitReader {
|
||||||
|
private let data: Data
|
||||||
|
private var idx: Int = 0
|
||||||
|
private var cur: UInt8 = 0
|
||||||
|
private var left: Int = 0
|
||||||
|
init(_ data: Data) {
|
||||||
|
self.data = data
|
||||||
|
if !data.isEmpty {
|
||||||
|
cur = data[0]
|
||||||
|
left = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func readBit() -> Int? {
|
||||||
|
if idx >= data.count { return nil }
|
||||||
|
let bit = (Int(cur) >> 7) & 1
|
||||||
|
cur = UInt8((Int(cur) << 1) & 0xFF)
|
||||||
|
left -= 1
|
||||||
|
if left == 0 {
|
||||||
|
idx += 1
|
||||||
|
if idx < data.count { cur = data[idx]; left = 8 }
|
||||||
|
}
|
||||||
|
return bit
|
||||||
|
}
|
||||||
|
func readUnary() -> Int? {
|
||||||
|
var q = 0
|
||||||
|
while true {
|
||||||
|
guard let b = readBit() else { return nil }
|
||||||
|
if b == 1 { q += 1 } else { break }
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
func readBits(count: Int) -> UInt64? {
|
||||||
|
var v: UInt64 = 0
|
||||||
|
for _ in 0..<count {
|
||||||
|
guard let b = readBit() else { return nil }
|
||||||
|
v = (v << 1) | UInt64(b)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// Gossip-based sync manager using on-demand GCS filters
|
||||||
|
final class GossipSyncManager {
|
||||||
|
protocol Delegate: AnyObject {
|
||||||
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
|
func sendPacket(to peerID: String, packet: BitchatPacket)
|
||||||
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
|
}
|
||||||
|
|
||||||
|
private let myPeerID: String
|
||||||
|
private let config: Config
|
||||||
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
|
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
||||||
|
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
||||||
|
private var messageOrder: [String] = []
|
||||||
|
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
|
|
||||||
|
init(myPeerID: String, config: Config = Config()) {
|
||||||
|
self.myPeerID = myPeerID
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
stop()
|
||||||
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
|
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
||||||
|
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
|
||||||
|
timer.resume()
|
||||||
|
periodicTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
periodicTimer?.cancel(); periodicTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
|
||||||
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
|
self?.sendRequestSync(to: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
|
let mt = MessageType(rawValue: packet.type)
|
||||||
|
let isBroadcastRecipient: Bool = {
|
||||||
|
guard let r = packet.recipientID else { return true }
|
||||||
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
|
}()
|
||||||
|
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
||||||
|
let isAnnounce = (mt == .announce)
|
||||||
|
guard isBroadcastMessage || isAnnounce else { return }
|
||||||
|
|
||||||
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
|
if isBroadcastMessage {
|
||||||
|
if messages[idHex] == nil {
|
||||||
|
messages[idHex] = packet
|
||||||
|
messageOrder.append(idHex)
|
||||||
|
// Enforce capacity
|
||||||
|
let cap = max(1, config.seenCapacity)
|
||||||
|
while messageOrder.count > cap {
|
||||||
|
let victim = messageOrder.removeFirst()
|
||||||
|
messages.removeValue(forKey: victim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if isAnnounce {
|
||||||
|
let sender = packet.senderID.hexEncodedString()
|
||||||
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRequestSync() {
|
||||||
|
let payload = buildGcsPayload()
|
||||||
|
let pkt = BitchatPacket(
|
||||||
|
type: MessageType.requestSync.rawValue,
|
||||||
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
|
recipientID: nil, // broadcast
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 0 // local-only
|
||||||
|
)
|
||||||
|
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
|
||||||
|
delegate?.sendPacket(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRequestSync(to peerID: String) {
|
||||||
|
let payload = buildGcsPayload()
|
||||||
|
var recipient = Data()
|
||||||
|
var temp = peerID
|
||||||
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
|
let hexByte = String(temp.prefix(2))
|
||||||
|
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||||
|
temp = String(temp.dropFirst(2))
|
||||||
|
}
|
||||||
|
let pkt = BitchatPacket(
|
||||||
|
type: MessageType.requestSync.rawValue,
|
||||||
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
|
recipientID: recipient,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 0 // local-only
|
||||||
|
)
|
||||||
|
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
|
||||||
|
delegate?.sendPacket(to: peerID, packet: signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||||
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
|
func mightContain(_ id: Data) -> Bool {
|
||||||
|
var hasher = SHA256()
|
||||||
|
hasher.update(data: id) // 16-byte PacketId
|
||||||
|
let digest = hasher.finalize()
|
||||||
|
let db = Data(digest)
|
||||||
|
var x: UInt64 = 0
|
||||||
|
let take = min(8, db.count)
|
||||||
|
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
|
||||||
|
let v = (x & 0x7fff_ffff_ffff_ffff) % UInt64(request.m)
|
||||||
|
return GCSFilter.contains(sortedValues: sorted, candidate: v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Announcements: send latest per peer if requester lacks them
|
||||||
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
|
let (idHex, pkt) = pair
|
||||||
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
|
if !mightContain(idBytes) {
|
||||||
|
var toSend = pkt
|
||||||
|
toSend.ttl = 0
|
||||||
|
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Broadcast messages: send all missing
|
||||||
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
|
for pkt in toSendMsgs {
|
||||||
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
|
if !mightContain(idBytes) {
|
||||||
|
var toSend = pkt
|
||||||
|
toSend.ttl = 0
|
||||||
|
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
|
private func buildGcsPayload() -> Data {
|
||||||
|
// Collect candidates: latest announce per peer + broadcast messages
|
||||||
|
var candidates: [BitchatPacket] = []
|
||||||
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
|
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
||||||
|
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
||||||
|
// Sort by timestamp desc
|
||||||
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
|
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||||
|
let cap = max(1, config.seenCapacity)
|
||||||
|
let takeN = min(candidates.count, min(nMax, cap))
|
||||||
|
if takeN <= 0 {
|
||||||
|
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
||||||
|
return req.encode()
|
||||||
|
}
|
||||||
|
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||||
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||||
|
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
||||||
|
return req.encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
|
func removeAnnouncementForPeer(_ peerID: String) {
|
||||||
|
let normalizedPeerID = peerID.lowercased()
|
||||||
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
|
|
||||||
|
// Remove messages from this peer
|
||||||
|
// Collect IDs to remove first to avoid concurrent modification
|
||||||
|
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||||
|
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove messages and update messageOrder
|
||||||
|
for id in messageIdsToRemove {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// Deterministic packet ID used for gossip sync membership
|
||||||
|
// ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload]
|
||||||
|
enum PacketIdUtil {
|
||||||
|
static func computeId(_ packet: BitchatPacket) -> Data {
|
||||||
|
var hasher = SHA256()
|
||||||
|
hasher.update(data: Data([packet.type]))
|
||||||
|
hasher.update(data: packet.senderID)
|
||||||
|
var tsBE = packet.timestamp.bigEndian
|
||||||
|
withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) }
|
||||||
|
hasher.update(data: packet.payload)
|
||||||
|
let digest = hasher.finalize()
|
||||||
|
return Data(digest.prefix(16))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func computeIdHex(_ packet: BitchatPacket) -> String {
|
||||||
|
return computeId(packet).hexEncodedString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// Color+Peer.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
private static var peerColorCache: [String: Color] = [:]
|
||||||
|
|
||||||
|
init(peerSeed: String, isDark: Bool) {
|
||||||
|
let cacheKey = peerSeed + (isDark ? "|dark" : "|light")
|
||||||
|
if let cached = Self.peerColorCache[cacheKey] {
|
||||||
|
self = cached
|
||||||
|
}
|
||||||
|
let h = peerSeed.djb2()
|
||||||
|
var hue = Double(h % 1000) / 1000.0
|
||||||
|
let orange = 30.0 / 360.0
|
||||||
|
if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {
|
||||||
|
hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)
|
||||||
|
}
|
||||||
|
let sRand = Double((h >> 17) & 0x3FF) / 1023.0
|
||||||
|
let bRand = Double((h >> 27) & 0x3FF) / 1023.0
|
||||||
|
let sBase: Double = isDark ? 0.80 : 0.70
|
||||||
|
let sRange: Double = 0.20
|
||||||
|
let bBase: Double = isDark ? 0.75 : 0.45
|
||||||
|
let bRange: Double = isDark ? 0.16 : 0.14
|
||||||
|
let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange))
|
||||||
|
let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange))
|
||||||
|
let c = Color(hue: hue, saturation: saturation, brightness: brightness)
|
||||||
|
Self.peerColorCache[cacheKey] = c
|
||||||
|
self = c
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// String+DJB2.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
func djb2() -> UInt64 {
|
||||||
|
var hash: UInt64 = 5381
|
||||||
|
for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// String+Nickname.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
/// Split a nickname into base and a '#abcd' suffix if present
|
||||||
|
func splitSuffix() -> (String, String) {
|
||||||
|
let name = self.replacingOccurrences(of: "@", with: "")
|
||||||
|
guard name.count >= 5 else { return (name, "") }
|
||||||
|
let suffix = String(name.suffix(5))
|
||||||
|
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||||
|
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||||
|
}) {
|
||||||
|
let base = String(name.dropLast(5))
|
||||||
|
return (base, suffix)
|
||||||
|
}
|
||||||
|
return (name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ChatViewModel+Extensions.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Deprecated helper. No longer needed after MessageHeaderLine simplification.
|
||||||
|
// Intentionally left empty to avoid accessing private properties across files.
|
||||||
|
|
||||||
|
// This file remains as a placeholder to preserve project references if any.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// ChatViewModel+Images.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Extracted image sending helpers to keep ChatViewModel compact.
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extension ChatViewModel {
|
||||||
|
// MARK: - Images (Send)
|
||||||
|
#if os(iOS)
|
||||||
|
@MainActor
|
||||||
|
func sendImage(_ image: UIImage) {
|
||||||
|
// Downscale on long edge to 512px with 85% JPEG quality (Android parity)
|
||||||
|
guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return }
|
||||||
|
|
||||||
|
// Persist to app files (outgoing)
|
||||||
|
guard let fileURL = saveOutgoingImage(data: data) else { return }
|
||||||
|
|
||||||
|
let name = fileURL.lastPathComponent
|
||||||
|
let mime = "image/jpeg"
|
||||||
|
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data)
|
||||||
|
guard let payload = tlv.encode() else { return }
|
||||||
|
let transferId = payload.sha256Hex()
|
||||||
|
|
||||||
|
let contentMarker = "[image] \(fileURL.path)"
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
if let peer = selectedPrivateChatPeer {
|
||||||
|
let messageID = UUID().uuidString
|
||||||
|
let msg = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: nickname,
|
||||||
|
content: contentMarker,
|
||||||
|
timestamp: now,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: meshService.peerNickname(peerID: peer),
|
||||||
|
senderPeerID: meshService.myPeerID,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: .sending
|
||||||
|
)
|
||||||
|
// appendToPrivateChat is private in ChatViewModel; inline minimal append
|
||||||
|
var arr = privateChats[peer] ?? []
|
||||||
|
arr.append(msg)
|
||||||
|
privateChats[peer] = arr
|
||||||
|
objectWillChange.send()
|
||||||
|
meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID)
|
||||||
|
} else {
|
||||||
|
let messageID = UUID().uuidString
|
||||||
|
let msg = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: nickname,
|
||||||
|
content: contentMarker,
|
||||||
|
timestamp: now,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: meshService.myPeerID,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: .sending
|
||||||
|
)
|
||||||
|
// public buffer helpers are private; append directly to visible messages
|
||||||
|
messages.append(msg)
|
||||||
|
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downscale without cropping; preserves aspect ratio. Long edge == maxDimension.
|
||||||
|
internal func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? {
|
||||||
|
let size = image.size
|
||||||
|
guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) }
|
||||||
|
let maxSide = max(size.width, size.height)
|
||||||
|
let scale = min(1.0, maxDimension / maxSide)
|
||||||
|
let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale))
|
||||||
|
|
||||||
|
let rendererFormat = UIGraphicsImageRendererFormat.default()
|
||||||
|
rendererFormat.scale = 1
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat)
|
||||||
|
let scaled = renderer.image { _ in
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: target))
|
||||||
|
}
|
||||||
|
return scaled.jpegData(compressionQuality: quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal func saveOutgoingImage(data: Data) -> URL? {
|
||||||
|
do {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let folder = base.appendingPathComponent("bitchat_images/outgoing", isDirectory: true)
|
||||||
|
if !fm.fileExists(atPath: folder.path) {
|
||||||
|
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
let ts = Int(Date().timeIntervalSince1970)
|
||||||
|
let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg")
|
||||||
|
try data.write(to: fileURL, options: .atomic)
|
||||||
|
return fileURL
|
||||||
|
} catch {
|
||||||
|
// Avoid BitLogger dependency in this small extension
|
||||||
|
print("❌ Failed to save outgoing image: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+292
-89
@@ -9,6 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import PhotosUI
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Supporting Types
|
// MARK: - Supporting Types
|
||||||
@@ -57,6 +58,21 @@ struct ContentView: View {
|
|||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@State private var windowCountPrivate: [String: Int] = [:]
|
@State private var windowCountPrivate: [String: Int] = [:]
|
||||||
|
// Measure input field height so recorder overlay matches it
|
||||||
|
@State private var inputFieldMeasuredHeight: CGFloat = 0
|
||||||
|
private struct InputHeightPreferenceKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat = 0
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
@State private var voiceRecorder = VoiceRecorder()
|
||||||
|
@State private var isRecordingVoice = false
|
||||||
|
@State private var recordingTimer: Timer? = nil
|
||||||
|
@State private var recordingElapsedMs: Int = 0
|
||||||
|
@State private var recordingAmplitudeNorm: CGFloat = 0
|
||||||
|
@State private var currentRecordingURL: URL? = nil
|
||||||
|
@State private var isShowingImagePicker = false
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
@@ -252,6 +268,16 @@ struct ContentView: View {
|
|||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.sheet(isPresented: $isShowingImagePicker) {
|
||||||
|
ImagePickerView { image in
|
||||||
|
if let img = image {
|
||||||
|
viewModel.sendImage(img)
|
||||||
|
}
|
||||||
|
isShowingImagePicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message List View
|
// MARK: - Message List View
|
||||||
@@ -305,20 +331,57 @@ struct ContentView: View {
|
|||||||
// Precompute heavy token scans once per row
|
// Precompute heavy token scans once per row
|
||||||
let cashuTokens = message.content.extractCashuTokens()
|
let cashuTokens = message.content.extractCashuTokens()
|
||||||
let lightningLinks = message.content.extractLightningLinks()
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
HStack(alignment: .top, spacing: 0) {
|
let isVoice = message.content.hasPrefix("[voice] ")
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
let isImage = message.content.hasPrefix("[image] ")
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
if isVoice {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
// Header line above media: sender + timestamp
|
||||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
MessageHeaderLine(message: message)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.environmentObject(viewModel)
|
||||||
|
// Hide the raw marker text; render custom voice view
|
||||||
// Delivery status indicator for private messages
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
let path = String(message.content.dropFirst("[voice] ".count))
|
||||||
|
let prog: Double? = {
|
||||||
|
if let st = message.deliveryStatus {
|
||||||
|
if case .partiallyDelivered(let r, let t) = st, t > 0 {
|
||||||
|
return min(1.0, max(0.0, Double(r) / Double(t)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
VoiceMessageRow(fileURL: URL(fileURLWithPath: path), sendProgress: prog)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
// Delivery status for our own private voice messages
|
||||||
if message.isPrivate && message.sender == viewModel.nickname,
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
let status = message.deliveryStatus {
|
let status = message.deliveryStatus {
|
||||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
|
} else if isImage {
|
||||||
|
// Header line above image: sender + timestamp
|
||||||
|
MessageHeaderLine(message: message)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
// Hide the raw marker text; render image view
|
||||||
|
let path = String(message.content.dropFirst("[image] ".count))
|
||||||
|
ImageMessageRow(path: path, message: message)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
} else {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||||
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Delivery status indicator for private messages
|
||||||
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
|
let status = message.deliveryStatus {
|
||||||
|
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
@@ -795,92 +858,171 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
TextField("type a message...", text: $messageText)
|
ZStack(alignment: .leading) {
|
||||||
.textFieldStyle(.plain)
|
// Always keep the TextField mounted so the keyboard state doesn't change
|
||||||
.font(.system(size: 14, design: .monospaced))
|
TextField("type a message...", text: $messageText)
|
||||||
.foregroundColor(textColor)
|
.textFieldStyle(.plain)
|
||||||
.focused($isTextFieldFocused)
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.padding(.leading, 12)
|
.foregroundColor(textColor)
|
||||||
// iOS keyboard autocomplete and capitalization enabled by default
|
.focused($isTextFieldFocused)
|
||||||
.onChange(of: messageText) { newValue in
|
.padding(.leading, 12)
|
||||||
// Cancel previous debounce timer
|
.opacity(isRecordingVoice ? 0.0 : 1.0)
|
||||||
autocompleteDebounceTimer?.invalidate()
|
// Measure the text field height so we can match recorder height
|
||||||
|
.background(GeometryReader { geo in
|
||||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
Color.clear.preference(key: InputHeightPreferenceKey.self, value: geo.size.height)
|
||||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
})
|
||||||
// Get cursor position (approximate - end of text for now)
|
|
||||||
let cursorPosition = newValue.count
|
// Recording overlay: live waveform + elapsed/max time, same height as input field
|
||||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
if isRecordingVoice {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
RealtimeScrollingWaveform(
|
||||||
|
amplitudeNorm: recordingAmplitudeNorm,
|
||||||
|
bars: 240,
|
||||||
|
barColor: (colorScheme == .dark ? Color.green : Color(red: 0, green: 0.6, blue: 0.3))
|
||||||
|
)
|
||||||
|
.frame(height: max(28, inputFieldMeasuredHeight))
|
||||||
|
let secs = max(0, recordingElapsedMs / 1000)
|
||||||
|
let mm = secs / 60
|
||||||
|
let ss = secs % 60
|
||||||
|
Text(String(format: "%02d:%02d / 00:10", mm, ss))
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.padding(.trailing, 4)
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 12)
|
||||||
// Check for command autocomplete (instant, no debounce needed)
|
.frame(height: max(28, inputFieldMeasuredHeight))
|
||||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
}
|
||||||
// Build context-aware command list
|
|
||||||
let isGeoPublic: Bool = {
|
}
|
||||||
if case .location = locationManager.selectedChannel { return true }
|
.onChange(of: messageText) { newValue in
|
||||||
return false
|
// Cancel previous debounce timer
|
||||||
}()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
|
||||||
var commandDescriptions = [
|
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||||
("/block", "block or list blocked peers"),
|
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||||
("/clear", "clear chat messages"),
|
// Get cursor position (approximate - end of text for now)
|
||||||
("/hug", "send someone a warm hug"),
|
let cursorPosition = newValue.count
|
||||||
("/m", "send private message"),
|
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||||
("/slap", "slap someone with a trout"),
|
}
|
||||||
("/unblock", "unblock a peer"),
|
|
||||||
("/w", "see who's online")
|
// Check for command autocomplete (instant, no debounce needed)
|
||||||
]
|
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||||
// Only show favorites commands when not in geohash context
|
// Build context-aware command list
|
||||||
if !(isGeoPublic || isGeoDM) {
|
let isGeoPublic: Bool = {
|
||||||
commandDescriptions.append(("/fav", "add to favorites"))
|
if case .location = locationManager.selectedChannel { return true }
|
||||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
return false
|
||||||
}
|
}()
|
||||||
|
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||||
let input = newValue.lowercased()
|
var commandDescriptions = [
|
||||||
|
("/block", "block or list blocked peers"),
|
||||||
// Map of aliases to primary commands
|
("/clear", "clear chat messages"),
|
||||||
let aliases: [String: String] = [
|
("/hug", "send someone a warm hug"),
|
||||||
"/join": "/j",
|
("/m", "send private message"),
|
||||||
"/msg": "/m"
|
("/slap", "slap someone with a trout"),
|
||||||
]
|
("/unblock", "unblock a peer"),
|
||||||
|
("/w", "see who's online")
|
||||||
// Filter commands, but convert aliases to primary
|
]
|
||||||
commandSuggestions = commandDescriptions
|
// Only show favorites commands when not in geohash context
|
||||||
.filter { $0.0.starts(with: input) }
|
if !(isGeoPublic || isGeoDM) {
|
||||||
.map { $0.0 }
|
commandDescriptions.append(("/fav", "add to favorites"))
|
||||||
|
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||||
// Also check if input matches an alias
|
}
|
||||||
for (alias, primary) in aliases {
|
|
||||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
let input = newValue.lowercased()
|
||||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
|
||||||
commandSuggestions.append(primary)
|
// Map of aliases to primary commands
|
||||||
}
|
let aliases: [String: String] = [
|
||||||
|
"/join": "/j",
|
||||||
|
"/msg": "/m"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Filter commands, but convert aliases to primary
|
||||||
|
commandSuggestions = commandDescriptions
|
||||||
|
.filter { $0.0.starts(with: input) }
|
||||||
|
.map { $0.0 }
|
||||||
|
|
||||||
|
// Also check if input matches an alias
|
||||||
|
for (alias, primary) in aliases {
|
||||||
|
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
||||||
|
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
||||||
|
commandSuggestions.append(primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove duplicates and sort
|
|
||||||
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
|
||||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
|
||||||
} else {
|
|
||||||
showCommandSuggestions = false
|
|
||||||
commandSuggestions = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove duplicates and sort
|
||||||
|
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
||||||
|
showCommandSuggestions = !commandSuggestions.isEmpty
|
||||||
|
} else {
|
||||||
|
showCommandSuggestions = false
|
||||||
|
commandSuggestions = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSubmit { sendMessage() }
|
||||||
|
.onPreferenceChange(InputHeightPreferenceKey.self) { inputFieldMeasuredHeight = $0 }
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if messageText.isEmpty {
|
||||||
|
#if os(iOS)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Plus button for image picker (hidden while recording)
|
||||||
|
if !isRecordingVoice {
|
||||||
|
Button(action: { showImagePicker() }) {
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Add media")
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundColor(isRecordingVoice ? .red : (viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor))
|
||||||
|
.highPriorityGesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { _ in
|
||||||
|
if !isRecordingVoice {
|
||||||
|
do {
|
||||||
|
let url = try VoiceRecorderPaths.outgoingURL()
|
||||||
|
try voiceRecorder.startRecording(to: url)
|
||||||
|
currentRecordingURL = url
|
||||||
|
isRecordingVoice = true
|
||||||
|
startRecordingMeters()
|
||||||
|
hapticStart()
|
||||||
|
} catch {
|
||||||
|
isRecordingVoice = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
finishRecordingAndSend()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
#else
|
||||||
|
// Non-iOS: keep send button placeholder
|
||||||
|
Button(action: sendMessage) {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(Color.gray)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
Button(action: sendMessage) {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
.accessibilityLabel("Send message")
|
||||||
|
.accessibilityHint("Double tap to send")
|
||||||
}
|
}
|
||||||
.onSubmit {
|
|
||||||
sendMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: sendMessage) {
|
|
||||||
Image(systemName: "arrow.up.circle.fill")
|
|
||||||
.font(.system(size: 20))
|
|
||||||
.foregroundColor(messageText.isEmpty ? Color.gray :
|
|
||||||
viewModel.selectedPrivateChatPeer != nil
|
|
||||||
? Color.orange : textColor)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
.accessibilityLabel("Send message")
|
|
||||||
.accessibilityHint(messageText.isEmpty ? "Enter a message to send" : "Double tap to send")
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
@@ -900,6 +1042,67 @@ struct ContentView: View {
|
|||||||
messageText = ""
|
messageText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
|
||||||
|
private func startRecordingMeters() {
|
||||||
|
recordingTimer?.invalidate()
|
||||||
|
recordingElapsedMs = 0
|
||||||
|
recordingAmplitudeNorm = 0
|
||||||
|
// Poll meters ~12.5Hz for elapsed and ~50Hz for waveform via the view's ticker.
|
||||||
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { _ in
|
||||||
|
recordingAmplitudeNorm = voiceRecorder.pollNormalizedAmplitude()
|
||||||
|
recordingElapsedMs = voiceRecorder.currentTimeMs()
|
||||||
|
if recordingElapsedMs >= 10_000 {
|
||||||
|
// Auto-stop at 10s
|
||||||
|
finishRecordingAndSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishRecordingAndSend() {
|
||||||
|
guard isRecordingVoice else { return }
|
||||||
|
voiceRecorder.stopRecording {
|
||||||
|
isRecordingVoice = false
|
||||||
|
recordingTimer?.invalidate(); recordingTimer = nil
|
||||||
|
hapticStop()
|
||||||
|
let url = currentRecordingURL
|
||||||
|
currentRecordingURL = nil
|
||||||
|
if let u = url {
|
||||||
|
viewModel.sendVoiceNote(fileURL: u)
|
||||||
|
} else {
|
||||||
|
// Fallback to latest file in case we lost the URL
|
||||||
|
do {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true)
|
||||||
|
let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
|
||||||
|
let latest = files.sorted { (a, b) -> Bool in
|
||||||
|
let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||||
|
let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||||
|
return ad > bd
|
||||||
|
}.first
|
||||||
|
if let u = latest { viewModel.sendVoiceNote(fileURL: u) }
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showImagePicker() {
|
||||||
|
isShowingImagePicker = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hapticStart() {
|
||||||
|
let gen = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
gen.prepare(); gen.impactOccurred()
|
||||||
|
}
|
||||||
|
private func hapticStop() {
|
||||||
|
let gen = UINotificationFeedbackGenerator()
|
||||||
|
gen.prepare(); gen.notificationOccurred(.success)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Sidebar View
|
// MARK: - Sidebar View
|
||||||
|
|
||||||
private var sidebarView: some View {
|
private var sidebarView: some View {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// ImageMessageRow.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Renders an image message bubble with rounded corners and optional delivery status.
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct ImageMessageRow: View {
|
||||||
|
let path: String
|
||||||
|
let message: BitchatMessage
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(iOS)
|
||||||
|
if let uiImage = UIImage(contentsOfFile: path) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
// Ensure image aligns with the same left edge as text messages
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Image(uiImage: uiImage)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(maxWidth: 300, maxHeight: 400, alignment: .topLeading)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
|
let status = message.deliveryStatus {
|
||||||
|
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
Text("⚠️ Image unavailable")
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Text("📷 Image: \(path)")
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// ImagePickerView.swift
|
||||||
|
// Simple UIKit-based image picker bridge
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
struct ImagePickerView: UIViewControllerRepresentable {
|
||||||
|
typealias UIViewControllerType = UIViewController
|
||||||
|
var onPicked: (UIImage?) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
var config = PHPickerConfiguration(photoLibrary: .shared())
|
||||||
|
config.filter = .images
|
||||||
|
config.selectionLimit = 1
|
||||||
|
let picker = PHPickerViewController(configuration: config)
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
return picker
|
||||||
|
} else {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
picker.sourceType = .photoLibrary
|
||||||
|
picker.allowsEditing = false
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(onPicked: onPicked)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, PHPickerViewControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
let onPicked: (UIImage?) -> Void
|
||||||
|
init(onPicked: @escaping (UIImage?) -> Void) { self.onPicked = onPicked }
|
||||||
|
|
||||||
|
// PHPicker
|
||||||
|
@available(iOS 14.0, *)
|
||||||
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
guard let item = results.first else { onPicked(nil); return }
|
||||||
|
if item.itemProvider.canLoadObject(ofClass: UIImage.self) {
|
||||||
|
item.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onPicked(object as? UIImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onPicked(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIImagePicker
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
onPicked(nil)
|
||||||
|
}
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||||
|
let image = info[.originalImage] as? UIImage
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
onPicked(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// MessageHeaderLine.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Renders a header like normal messages: "<@nickname> [HH:mm:ss]"
|
||||||
|
// Used above media rows (images, audio) to match text message style.
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MessageHeaderLine: View {
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
let message: BitchatMessage
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(formattedHeader())
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedHeader() -> AttributedString {
|
||||||
|
var result = AttributedString()
|
||||||
|
let isDark = colorScheme == .dark
|
||||||
|
let baseColor = viewModel.peerColor(for: message, isDark: isDark)
|
||||||
|
|
||||||
|
// Determine self to bold our own name
|
||||||
|
let isSelf: Bool = {
|
||||||
|
if let spid = message.senderPeerID {
|
||||||
|
return spid == viewModel.meshService.myPeerID
|
||||||
|
}
|
||||||
|
return message.sender == viewModel.nickname || message.sender.hasPrefix(viewModel.nickname + "#")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Split suffix like name#abcd
|
||||||
|
let (base, suffix) = message.sender.splitSuffix()
|
||||||
|
|
||||||
|
var senderStyle = AttributeContainer()
|
||||||
|
senderStyle.foregroundColor = baseColor
|
||||||
|
senderStyle.font = .system(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
|
||||||
|
if let spid = message.senderPeerID,
|
||||||
|
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||||
|
senderStyle.link = url
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<@"
|
||||||
|
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||||
|
// base name
|
||||||
|
result.append(AttributedString(base).mergingAttributes(senderStyle))
|
||||||
|
// optional suffix with lighter color
|
||||||
|
if !suffix.isEmpty {
|
||||||
|
var suf = senderStyle
|
||||||
|
suf.foregroundColor = baseColor.opacity(0.6)
|
||||||
|
result.append(AttributedString(suffix).mergingAttributes(suf))
|
||||||
|
}
|
||||||
|
// Close angle without adding content or ">"
|
||||||
|
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||||
|
|
||||||
|
// Timestamp like normal messages
|
||||||
|
let ts = AttributedString("[\(message.formattedTimestamp)]")
|
||||||
|
var tsStyle = AttributeContainer()
|
||||||
|
tsStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||||
|
tsStyle.font = .system(size: 10, design: .monospaced)
|
||||||
|
result.append(ts.mergingAttributes(tsStyle))
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Real-time scrolling waveform for live recording.
|
||||||
|
/// Provide a normalized amplitude [0,1]. The view maintains a sliding window of bars.
|
||||||
|
struct RealtimeScrollingWaveform: View {
|
||||||
|
var amplitudeNorm: CGFloat
|
||||||
|
var bars: Int = 240
|
||||||
|
var barColor: Color = Color(red: 0, green: 1, blue: 0.5) // neon-ish green
|
||||||
|
|
||||||
|
@State private var samples: [CGFloat] = []
|
||||||
|
@State private var ticker = Timer.publish(every: 0.02, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
Canvas { ctx, size in
|
||||||
|
let w = size.width
|
||||||
|
let h = size.height
|
||||||
|
guard w > 0 && h > 0 else { return }
|
||||||
|
let n = samples.count
|
||||||
|
guard n > 0 else { return }
|
||||||
|
let stepX = w / CGFloat(n)
|
||||||
|
let midY = h / 2
|
||||||
|
let stroke: CGFloat = 1.2
|
||||||
|
|
||||||
|
for i in 0..<n {
|
||||||
|
let amp = max(0, min(1, samples[i]))
|
||||||
|
// Amplify only higher amplitudes so quiet parts stay subtle
|
||||||
|
let t: CGFloat = 0.6
|
||||||
|
let k: CGFloat = 0.7
|
||||||
|
let boosted = amp <= t ? amp : min(1, amp + k * (amp - t))
|
||||||
|
let lineH = max(1, boosted * (h * 0.95))
|
||||||
|
let x = CGFloat(i) * stepX + stepX / 2
|
||||||
|
let yTop = midY - lineH / 2
|
||||||
|
let yBot = midY + lineH / 2
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: CGPoint(x: x, y: yTop))
|
||||||
|
path.addLine(to: CGPoint(x: x, y: yBot))
|
||||||
|
ctx.stroke(path, with: .color(barColor), lineWidth: stroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if samples.isEmpty { samples = Array(repeating: 0, count: bars) }
|
||||||
|
}
|
||||||
|
.onChange(of: bars) { newVal in
|
||||||
|
let clamped = max(8, newVal)
|
||||||
|
samples = Array(samples.suffix(clamped))
|
||||||
|
if samples.count < clamped {
|
||||||
|
samples.insert(contentsOf: Array(repeating: 0, count: clamped - samples.count), at: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(ticker) { _ in
|
||||||
|
let v = max(0, min(1, amplitudeNorm))
|
||||||
|
samples.append(v)
|
||||||
|
let overflow = samples.count - bars
|
||||||
|
if overflow > 0 { samples.removeFirst(overflow) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct VoiceMessageRow: View {
|
||||||
|
let fileURL: URL
|
||||||
|
var sendProgress: Double? = nil // 0..1 filling during send
|
||||||
|
@State private var bins: [Float] = []
|
||||||
|
@State private var isPlaying = false
|
||||||
|
@State private var progress: Double = 0
|
||||||
|
@State private var duration: TimeInterval = 0
|
||||||
|
@State private var player: AVAudioPlayer?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
// Play / Pause control (fixed size similar to Android's 28dp)
|
||||||
|
Button(action: togglePlay) {
|
||||||
|
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Waveform takes remaining width, fixed height, vertically centered
|
||||||
|
GeometryReader { geo in
|
||||||
|
let barWidth: CGFloat = 2
|
||||||
|
let barSpacing: CGFloat = 2
|
||||||
|
let step = barWidth + barSpacing
|
||||||
|
let maxBars = max(0, Int(floor(geo.size.width / step)))
|
||||||
|
let displayCount = min(maxBars, bins.count)
|
||||||
|
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
// Base waveform (gray)
|
||||||
|
HStack(spacing: barSpacing) {
|
||||||
|
ForEach(0..<displayCount, id: \.self) { i in
|
||||||
|
let v = bins[i]
|
||||||
|
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
|
||||||
|
let h = max(2, CGFloat(normalizedV) * geo.size.height)
|
||||||
|
VStack { Spacer(minLength: 0); Capsule().fill(Color.gray.opacity(0.4)).frame(width: barWidth, height: h); Spacer(minLength: 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filled progress (blue) or send progress if provided
|
||||||
|
let activeProgress = sendProgress ?? progress
|
||||||
|
let filledBars = Int(Double(displayCount) * activeProgress)
|
||||||
|
HStack(spacing: barSpacing) {
|
||||||
|
ForEach(0..<max(0, min(displayCount, filledBars)), id: \.self) { i in
|
||||||
|
let v = bins[i]
|
||||||
|
let normalizedV = v <= 0 ? 0 : min(1.0, log(1.0 + v * 9.0) / log(10.0))
|
||||||
|
let h = max(2, CGFloat(normalizedV) * geo.size.height)
|
||||||
|
VStack { Spacer(minLength: 0); Capsule().fill(Color.blue).frame(width: barWidth, height: h); Spacer(minLength: 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped() // prevent overflow beyond available width/height
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
|
||||||
|
// Reserve width for duration to avoid being pushed out by waveform
|
||||||
|
Text(formattedDuration)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.onAppear(perform: load)
|
||||||
|
.onDisappear { player?.stop(); isPlaying = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedDuration: String {
|
||||||
|
let total = duration
|
||||||
|
let mins = Int(total) / 60
|
||||||
|
let secs = Int(total) % 60
|
||||||
|
return String(format: "%d:%02d", mins, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
bins = WaveformExtractor.extractBins(url: fileURL, binCount: 120)
|
||||||
|
do {
|
||||||
|
player = try AVAudioPlayer(contentsOf: fileURL)
|
||||||
|
duration = player?.duration ?? 0
|
||||||
|
player?.prepareToPlay()
|
||||||
|
startProgressTimer()
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func togglePlay() {
|
||||||
|
guard let p = player else { return }
|
||||||
|
if isPlaying { p.pause(); isPlaying = false }
|
||||||
|
else { p.play(); isPlaying = true; startProgressTimer() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startProgressTimer() {
|
||||||
|
guard let p = player else { return }
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in
|
||||||
|
if !isPlaying || p.duration == 0 { t.invalidate(); return }
|
||||||
|
progress = min(1.0, p.currentTime / p.duration)
|
||||||
|
if progress >= 1.0 { isPlaying = false; t.invalidate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.chat.bitchat</string>
|
<string>group.chat.bitchat-local</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.chat.bitchat</string>
|
<string>group.chat.bitchat-local</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import UniformTypeIdentifiers
|
|||||||
/// Modern share extension using UIKit + UTTypes.
|
/// Modern share extension using UIKit + UTTypes.
|
||||||
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
||||||
final class ShareViewController: UIViewController {
|
final class ShareViewController: UIViewController {
|
||||||
|
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||||
|
private static let groupID = "group.chat.bitchat"
|
||||||
|
|
||||||
private let statusLabel: UILabel = {
|
private let statusLabel: UILabel = {
|
||||||
let l = UILabel()
|
let l = UILabel()
|
||||||
l.translatesAutoresizingMaskIntoConstraints = false
|
l.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -149,7 +152,7 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveToSharedDefaults(content: String, type: String) {
|
private func saveToSharedDefaults(content: String, type: String) {
|
||||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
|
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
|
||||||
userDefaults.set(content, forKey: "sharedContent")
|
userDefaults.set(content, forKey: "sharedContent")
|
||||||
userDefaults.set(type, forKey: "sharedContentType")
|
userDefaults.set(type, forKey: "sharedContentType")
|
||||||
userDefaults.set(Date(), forKey: "sharedContentDate")
|
userDefaults.set(Date(), forKey: "sharedContentDate")
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
let expectation = XCTestExpectation(description: "Delivery handler called")
|
let expectation = XCTestExpectation(description: "Delivery handler called")
|
||||||
|
|
||||||
service.packetDeliveryHandler = { packet in
|
service.packetDeliveryHandler = { packet in
|
||||||
if let msg = BitchatMessage.fromBinaryPayload(packet.payload) {
|
if let msg = BitchatMessage(packet.payload) {
|
||||||
XCTAssertEqual(msg.content, "Test delivery")
|
XCTAssertEqual(msg.content, "Test delivery")
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ final class PrivateChatE2ETests: XCTestCase {
|
|||||||
alice.packetDeliveryHandler = { packet in
|
alice.packetDeliveryHandler = { packet in
|
||||||
// Encrypt outgoing private messages
|
// Encrypt outgoing private messages
|
||||||
if packet.type == 0x01,
|
if packet.type == 0x01,
|
||||||
let message = BitchatMessage.fromBinaryPayload(packet.payload),
|
let message = BitchatMessage(packet.payload),
|
||||||
message.isPrivate {
|
message.isPrivate {
|
||||||
do {
|
do {
|
||||||
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
|
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
|
||||||
@@ -164,7 +164,7 @@ final class PrivateChatE2ETests: XCTestCase {
|
|||||||
if packet.type == 0x02 {
|
if packet.type == 0x02 {
|
||||||
do {
|
do {
|
||||||
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
||||||
if let message = BitchatMessage.fromBinaryPayload(decrypted) {
|
if let message = BitchatMessage(decrypted) {
|
||||||
XCTAssertEqual(message.content, TestConstants.testMessage1)
|
XCTAssertEqual(message.content, TestConstants.testMessage1)
|
||||||
XCTAssertTrue(message.isPrivate)
|
XCTAssertTrue(message.isPrivate)
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
// Set up relay in Bob
|
// Set up relay in Bob
|
||||||
bob.packetDeliveryHandler = { packet in
|
bob.packetDeliveryHandler = { packet in
|
||||||
// Bob should relay to Charlie
|
// Bob should relay to Charlie
|
||||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload),
|
if let message = BitchatMessage(packet.payload),
|
||||||
message.sender == TestConstants.testNickname1 {
|
message.sender == TestConstants.testNickname1 {
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
@@ -437,7 +437,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
// Check if should relay
|
// Check if should relay
|
||||||
guard packet.ttl > 1 else { return }
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
// Setup encryption at Alice
|
// Setup encryption at Alice
|
||||||
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||||
if packet.type == 0x01,
|
if packet.type == 0x01,
|
||||||
let message = BitchatMessage.fromBinaryPayload(packet.payload),
|
let message = BitchatMessage(packet.payload),
|
||||||
message.isPrivate && packet.recipientID != nil {
|
message.isPrivate && packet.recipientID != nil {
|
||||||
// Encrypt private messages
|
// Encrypt private messages
|
||||||
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
||||||
@@ -553,7 +553,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
if packet.type == 0x02 {
|
if packet.type == 0x02 {
|
||||||
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
||||||
let message = BitchatMessage.fromBinaryPayload(decrypted) {
|
let message = BitchatMessage(decrypted) {
|
||||||
bobDecrypted = message.content == "Secret message"
|
bobDecrypted = message.content == "Secret message"
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
@@ -626,7 +626,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
node.packetDeliveryHandler = { packet in
|
node.packetDeliveryHandler = { packet in
|
||||||
guard packet.ttl > 1 else { return }
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ final class MockBLEService: NSObject {
|
|||||||
|
|
||||||
func simulateIncomingPacket(_ packet: BitchatPacket) {
|
func simulateIncomingPacket(_ packet: BitchatPacket) {
|
||||||
// Process through the actual handling logic
|
// Process through the actual handling logic
|
||||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
var shouldDeliver = false
|
var shouldDeliver = false
|
||||||
seenLock.lock()
|
seenLock.lock()
|
||||||
if !seenMessageIDs.contains(message.id) {
|
if !seenMessageIDs.contains(message.id) {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
guard let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to decode message from binary")
|
XCTFail("Failed to decode message from binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to encode/decode private message")
|
XCTFail("Failed to encode/decode private message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
let message = TestHelpers.createTestMessage(mentions: mentions)
|
let message = TestHelpers.createTestMessage(mentions: mentions)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to encode/decode message with mentions")
|
XCTFail("Failed to encode/decode message with mentions")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to encode/decode relay message")
|
XCTFail("Failed to encode/decode relay message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
let message = TestHelpers.createTestMessage(content: largeContent)
|
let message = TestHelpers.createTestMessage(content: largeContent)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to handle large message")
|
XCTFail("Failed to handle large message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,7 +307,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
let emptyMessage = TestHelpers.createTestMessage(content: "")
|
let emptyMessage = TestHelpers.createTestMessage(content: "")
|
||||||
|
|
||||||
guard let payload = emptyMessage.toBinaryPayload(),
|
guard let payload = emptyMessage.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to handle empty message")
|
XCTFail("Failed to handle empty message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "BitLogger",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v16),
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "BitLogger",
|
||||||
|
targets: ["BitLogger"]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "BitLogger",
|
||||||
|
path: "Sources"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// OSLog+Categories.swift
|
// OSLog+Categories.swift
|
||||||
// bitchat
|
// BitLogger
|
||||||
//
|
//
|
||||||
// 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>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import os.log
|
import os.log
|
||||||
|
|
||||||
extension OSLog {
|
public extension OSLog {
|
||||||
private static let subsystem = "chat.bitchat"
|
private static let subsystem = "chat.bitchat"
|
||||||
|
|
||||||
static let noise = OSLog(subsystem: subsystem, category: "noise")
|
static let noise = OSLog(subsystem: subsystem, category: "noise")
|
||||||
+49
-45
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// SecureLogger.swift
|
// SecureLogger.swift
|
||||||
// bitchat
|
// BitLogger
|
||||||
//
|
//
|
||||||
// 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>
|
||||||
@@ -11,7 +11,7 @@ import os.log
|
|||||||
|
|
||||||
/// Centralized security-aware logging framework
|
/// Centralized security-aware logging framework
|
||||||
/// Provides safe logging that filters sensitive data and security events
|
/// Provides safe logging that filters sensitive data and security events
|
||||||
final class SecureLogger {
|
public final class SecureLogger {
|
||||||
|
|
||||||
// MARK: - Timestamp Formatter
|
// MARK: - Timestamp Formatter
|
||||||
|
|
||||||
@@ -85,8 +85,50 @@ final class SecureLogger {
|
|||||||
private static func shouldLog(_ level: LogLevel) -> Bool {
|
private static func shouldLog(_ level: LogLevel) -> Bool {
|
||||||
return level.order >= minimumLevel.order
|
return level.order >= minimumLevel.order
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Logging Methods
|
||||||
|
|
||||||
|
public extension SecureLogger {
|
||||||
|
|
||||||
// MARK: - Security Event Types
|
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
|
log(message(), category: category, level: .debug, file: file, line: line, function: function)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
|
log(message(), category: category, level: .info, file: file, line: line, function: function)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
|
log(message(), category: category, level: .warning, file: file, line: line, function: function)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
|
log(message(), category: category, level: .error, file: file, line: line, function: function)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log errors with context
|
||||||
|
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
|
let sanitized = sanitize(context())
|
||||||
|
let errorDesc = sanitize(error.localizedDescription)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
|
#else
|
||||||
|
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Security Event Logging
|
||||||
|
|
||||||
|
public extension SecureLogger {
|
||||||
|
|
||||||
enum SecurityEvent {
|
enum SecurityEvent {
|
||||||
case handshakeStarted(peerID: String)
|
case handshakeStarted(peerID: String)
|
||||||
@@ -111,30 +153,6 @@ final class SecureLogger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Logging Methods
|
|
||||||
|
|
||||||
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
log(message(), category: category, level: .debug, file: file, line: line, function: function)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
log(message(), category: category, level: .info, file: file, line: line, function: function)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
log(message(), category: category, level: .warning, file: file, line: line, function: function)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
log(message(), category: category, level: .error, file: file, line: line, function: function)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Security Event Logging
|
|
||||||
|
|
||||||
static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
|
static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
logSecurityEvent(event, level: .debug, file: file, line: line, function: function)
|
logSecurityEvent(event, level: .debug, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
@@ -150,25 +168,11 @@ final class SecureLogger {
|
|||||||
static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
|
static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
logSecurityEvent(event, level: .error, file: file, line: line, function: function)
|
logSecurityEvent(event, level: .error, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log errors with context
|
|
||||||
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
|
||||||
let sanitized = sanitize(context())
|
|
||||||
let errorDesc = sanitize(error.localizedDescription)
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
|
||||||
#else
|
|
||||||
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Convenience Extensions
|
// MARK: - Convenience Extensions
|
||||||
|
|
||||||
extension SecureLogger {
|
public extension SecureLogger {
|
||||||
|
|
||||||
enum KeyOperation: String, CustomStringConvertible {
|
enum KeyOperation: String, CustomStringConvertible {
|
||||||
case load
|
case load
|
||||||
@@ -177,7 +181,7 @@ extension SecureLogger {
|
|||||||
case delete
|
case delete
|
||||||
case save
|
case save
|
||||||
|
|
||||||
var description: String { rawValue }
|
public var description: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log key management operations
|
/// Log key management operations
|
||||||
@@ -290,8 +294,8 @@ private extension SecureLogger {
|
|||||||
|
|
||||||
/// Helper to migrate from print statements to SecureLogger
|
/// Helper to migrate from print statements to SecureLogger
|
||||||
/// Usage: Replace print(...) with secureLog(...)
|
/// Usage: Replace print(...) with secureLog(...)
|
||||||
func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n",
|
public func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n",
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let message = items.map { String(describing: $0) }.joined(separator: separator)
|
let message = items.map { String(describing: $0) }.joined(separator: separator)
|
||||||
SecureLogger.debug(message, file: file, line: line, function: function)
|
SecureLogger.debug(message, file: file, line: line, function: function)
|
||||||
-236
@@ -1,236 +0,0 @@
|
|||||||
name: bitchat
|
|
||||||
options:
|
|
||||||
bundleIdPrefix: chat.bitchat
|
|
||||||
deploymentTarget:
|
|
||||||
iOS: 16.0
|
|
||||||
macOS: 13.0
|
|
||||||
createIntermediateGroups: true
|
|
||||||
|
|
||||||
settings:
|
|
||||||
MARKETING_VERSION: 1.0.0
|
|
||||||
CURRENT_PROJECT_VERSION: 1
|
|
||||||
|
|
||||||
packages:
|
|
||||||
P256K:
|
|
||||||
url: https://github.com/21-DOT-DEV/swift-secp256k1
|
|
||||||
majorVersion: 0.21.1
|
|
||||||
|
|
||||||
targets:
|
|
||||||
bitchat_iOS:
|
|
||||||
type: application
|
|
||||||
platform: iOS
|
|
||||||
sources:
|
|
||||||
- bitchat
|
|
||||||
resources:
|
|
||||||
- bitchat/Assets.xcassets
|
|
||||||
- bitchat/LaunchScreen.storyboard
|
|
||||||
info:
|
|
||||||
path: bitchat/Info.plist
|
|
||||||
properties:
|
|
||||||
CFBundleDisplayName: bitchat
|
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
|
||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
|
||||||
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
|
|
||||||
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
|
|
||||||
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
|
|
||||||
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
|
|
||||||
UIBackgroundModes:
|
|
||||||
- bluetooth-central
|
|
||||||
- bluetooth-peripheral
|
|
||||||
UILaunchStoryboardName: LaunchScreen
|
|
||||||
UISupportedInterfaceOrientations:
|
|
||||||
- UIInterfaceOrientationPortrait
|
|
||||||
UISupportedInterfaceOrientations~ipad:
|
|
||||||
- UIInterfaceOrientationPortrait
|
|
||||||
- UIInterfaceOrientationPortraitUpsideDown
|
|
||||||
- UIInterfaceOrientationLandscapeLeft
|
|
||||||
- UIInterfaceOrientationLandscapeRight
|
|
||||||
UIRequiresFullScreen: false
|
|
||||||
CFBundleURLTypes:
|
|
||||||
- CFBundleURLSchemes:
|
|
||||||
- bitchat
|
|
||||||
# xcodegen quirk: include some macOS properties in iOS target
|
|
||||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
|
|
||||||
PRODUCT_NAME: bitchat
|
|
||||||
INFOPLIST_FILE: bitchat/Info.plist
|
|
||||||
ENABLE_PREVIEWS: YES
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: 16.0
|
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
|
||||||
CODE_SIGN_ENTITLEMENTS: bitchat/bitchat.entitlements
|
|
||||||
dependencies:
|
|
||||||
- target: bitchatShareExtension
|
|
||||||
embed: true
|
|
||||||
- package: P256K
|
|
||||||
- framework: Frameworks/tor-nolzma.xcframework
|
|
||||||
embed: true
|
|
||||||
codeSign: true
|
|
||||||
- sdk: libz.tbd
|
|
||||||
|
|
||||||
bitchat_macOS:
|
|
||||||
type: application
|
|
||||||
platform: macOS
|
|
||||||
sources:
|
|
||||||
- bitchat
|
|
||||||
resources:
|
|
||||||
- bitchat/Assets.xcassets
|
|
||||||
info:
|
|
||||||
path: bitchat/Info.plist
|
|
||||||
properties:
|
|
||||||
CFBundleDisplayName: bitchat
|
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
|
||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
|
||||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
|
||||||
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
|
|
||||||
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
|
|
||||||
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
|
|
||||||
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
|
|
||||||
CFBundleURLTypes:
|
|
||||||
- CFBundleURLSchemes:
|
|
||||||
- bitchat
|
|
||||||
# xcodegen quirk: include some iOS properties in macOS target
|
|
||||||
UIBackgroundModes:
|
|
||||||
- bluetooth-central
|
|
||||||
- bluetooth-peripheral
|
|
||||||
UILaunchStoryboardName: LaunchScreen
|
|
||||||
UISupportedInterfaceOrientations:
|
|
||||||
- UIInterfaceOrientationPortrait
|
|
||||||
UIRequiresFullScreen: true
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
|
|
||||||
PRODUCT_NAME: bitchat
|
|
||||||
INFOPLIST_FILE: bitchat/Info.plist
|
|
||||||
ENABLE_PREVIEWS: NO
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
MACOSX_DEPLOYMENT_TARGET: 13.0
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
|
||||||
CODE_SIGN_ENTITLEMENTS: bitchat/bitchat-macOS.entitlements
|
|
||||||
dependencies:
|
|
||||||
- package: P256K
|
|
||||||
- framework: Frameworks/tor-nolzma.xcframework
|
|
||||||
embed: true
|
|
||||||
codeSign: true
|
|
||||||
- sdk: libz.tbd
|
|
||||||
|
|
||||||
bitchatShareExtension:
|
|
||||||
type: app-extension
|
|
||||||
platform: iOS
|
|
||||||
sources:
|
|
||||||
- bitchatShareExtension
|
|
||||||
- bitchat/Services/TransportConfig.swift
|
|
||||||
info:
|
|
||||||
path: bitchatShareExtension/Info.plist
|
|
||||||
properties:
|
|
||||||
CFBundleDisplayName: bitchat
|
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
|
||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
|
||||||
NSExtension:
|
|
||||||
NSExtensionPointIdentifier: com.apple.share-services
|
|
||||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
|
|
||||||
NSExtensionAttributes:
|
|
||||||
NSExtensionActivationRule:
|
|
||||||
NSExtensionActivationSupportsText: true
|
|
||||||
NSExtensionActivationSupportsWebURLWithMaxCount: 1
|
|
||||||
NSExtensionActivationSupportsImageWithMaxCount: 1
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.ShareExtension
|
|
||||||
INFOPLIST_FILE: bitchatShareExtension/Info.plist
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: 16.0
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
CODE_SIGN_ENTITLEMENTS: bitchatShareExtension/bitchatShareExtension.entitlements
|
|
||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES
|
|
||||||
|
|
||||||
bitchatTests_iOS:
|
|
||||||
type: bundle.unit-test
|
|
||||||
platform: iOS
|
|
||||||
sources:
|
|
||||||
- bitchatTests
|
|
||||||
dependencies:
|
|
||||||
- target: bitchat_iOS
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
|
|
||||||
INFOPLIST_FILE: bitchatTests/Info.plist
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: 16.0
|
|
||||||
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat
|
|
||||||
BUNDLE_LOADER: $(TEST_HOST)
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
|
|
||||||
bitchatTests_macOS:
|
|
||||||
type: bundle.unit-test
|
|
||||||
platform: macOS
|
|
||||||
sources:
|
|
||||||
- bitchatTests
|
|
||||||
dependencies:
|
|
||||||
- target: bitchat_macOS
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
|
|
||||||
INFOPLIST_FILE: bitchatTests/Info.plist
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
MACOSX_DEPLOYMENT_TARGET: 13.0
|
|
||||||
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat
|
|
||||||
BUNDLE_LOADER: $(TEST_HOST)
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
|
|
||||||
schemes:
|
|
||||||
bitchat (iOS):
|
|
||||||
build:
|
|
||||||
targets:
|
|
||||||
bitchat_iOS: all
|
|
||||||
bitchatShareExtension: all
|
|
||||||
run:
|
|
||||||
config: Debug
|
|
||||||
executable: bitchat_iOS
|
|
||||||
test:
|
|
||||||
config: Debug
|
|
||||||
targets:
|
|
||||||
- bitchatTests_iOS
|
|
||||||
profile:
|
|
||||||
config: Release
|
|
||||||
executable: bitchat_iOS
|
|
||||||
analyze:
|
|
||||||
config: Debug
|
|
||||||
archive:
|
|
||||||
config: Release
|
|
||||||
|
|
||||||
bitchat (macOS):
|
|
||||||
build:
|
|
||||||
targets:
|
|
||||||
bitchat_macOS: all
|
|
||||||
run:
|
|
||||||
config: Debug
|
|
||||||
executable: bitchat_macOS
|
|
||||||
test:
|
|
||||||
config: Debug
|
|
||||||
targets:
|
|
||||||
- bitchatTests_macOS
|
|
||||||
profile:
|
|
||||||
config: Release
|
|
||||||
executable: bitchat_macOS
|
|
||||||
analyze:
|
|
||||||
config: Debug
|
|
||||||
archive:
|
|
||||||
config: Release
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "bitchat Setup Script"
|
|
||||||
echo "==================="
|
|
||||||
|
|
||||||
# Check if XcodeGen is installed
|
|
||||||
if command -v xcodegen &> /dev/null; then
|
|
||||||
echo "✓ XcodeGen found"
|
|
||||||
echo "Generating Xcode project..."
|
|
||||||
xcodegen generate
|
|
||||||
echo "✓ Project generated successfully"
|
|
||||||
echo ""
|
|
||||||
echo "To open the project, run:"
|
|
||||||
echo " open bitchat.xcodeproj"
|
|
||||||
else
|
|
||||||
echo "⚠️ XcodeGen not found"
|
|
||||||
echo ""
|
|
||||||
echo "You have several options:"
|
|
||||||
echo "1. Install XcodeGen:"
|
|
||||||
echo " brew install xcodegen"
|
|
||||||
echo ""
|
|
||||||
echo "2. Open with Swift Package Manager:"
|
|
||||||
echo " open Package.swift"
|
|
||||||
echo ""
|
|
||||||
echo "3. Create a new Xcode project manually and add the source files"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Project Structure:"
|
|
||||||
echo "- bitchat/ Main source files"
|
|
||||||
echo " - BitchatApp.swift App entry point"
|
|
||||||
echo " - Views/ SwiftUI views"
|
|
||||||
echo " - ViewModels/ View models"
|
|
||||||
echo " - Services/ Bluetooth and encryption"
|
|
||||||
echo " - Protocols/ Protocol definitions"
|
|
||||||
echo ""
|
|
||||||
echo "Remember to:"
|
|
||||||
echo "1. Enable Bluetooth in device settings"
|
|
||||||
echo "2. Run on physical devices (Bluetooth doesn't work in simulator)"
|
|
||||||
echo "3. Test with multiple devices for mesh functionality"
|
|
||||||
Reference in New Issue
Block a user