mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:45:20 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ae8da5f9 | ||
|
|
54bb812469 | ||
|
|
482fca81ef | ||
|
|
b2e7d2d26e | ||
|
|
6a2832d22b | ||
|
|
56e7324069 | ||
|
|
1733dda6cd | ||
|
|
00ff5fd31c | ||
|
|
f684c452b2 |
@@ -0,0 +1,20 @@
|
||||
# Prevent Github Languages stats skewing:
|
||||
|
||||
# Binaries and assets
|
||||
**/*.xcframework/** linguist-vendored
|
||||
**/*.xcassets/** linguist-vendored
|
||||
|
||||
# Generated files
|
||||
**/*.pbxproj linguist-generated
|
||||
**/*.storyboard linguist-generated
|
||||
Package.resolved linguist-generated
|
||||
|
||||
# Downloaded CSVs
|
||||
relays/online_relays_gps.csv linguist-vendored
|
||||
|
||||
# Docs
|
||||
**/*.md linguist-documentation
|
||||
|
||||
# Configs
|
||||
Configs/*.xcconfig linguist-documentation
|
||||
**/*.plist linguist-documentation
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.0
|
||||
MARKETING_VERSION = 1.4.2
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -14,7 +14,6 @@ default:
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
@@ -22,8 +21,6 @@ check:
|
||||
# Backup original files
|
||||
backup:
|
||||
@echo "Backing up original project configuration..."
|
||||
@cp project.yml project.yml.backup 2>/dev/null || true
|
||||
@# Backup other files that get modified by xcodegen
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||
|
||||
@@ -44,15 +41,10 @@ patch-for-macos: backup
|
||||
@# Move iOS-specific files out of the way temporarily
|
||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||
|
||||
# Generate Xcode project with patches
|
||||
generate: patch-for-macos
|
||||
@echo "Generating Xcode project..."
|
||||
@xcodegen generate
|
||||
|
||||
# Build the macOS app
|
||||
build: check generate
|
||||
build: #check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
@@ -75,9 +67,7 @@ clean: restore
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@echo "Quick development build..."
|
||||
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
|
||||
@xcodegen generate
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Show app info
|
||||
@@ -106,11 +96,9 @@ nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||
@rm -f project.yml.backup 2>/dev/null || true
|
||||
@rm -f project-macos.yml 2>/dev/null || true
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@echo "✅ Nuclear clean complete"
|
||||
|
||||
Generated
+132
-8
@@ -11,6 +11,7 @@
|
||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||
@@ -27,6 +28,13 @@
|
||||
remoteGlobalIDString = AF077EA0474EDEDE2C72716C;
|
||||
remoteInfo = bitchat_iOS;
|
||||
};
|
||||
E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 57CA17A36A2532A6CFF367BB;
|
||||
remoteInfo = bitchatShareExtension;
|
||||
};
|
||||
FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 475D96681D0EA0AE57A4E06E /* Project object */;
|
||||
@@ -42,6 +50,7 @@
|
||||
dstPath = "";
|
||||
dstSubfolder = PlugIns;
|
||||
files = (
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
};
|
||||
@@ -51,6 +60,7 @@
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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; };
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -58,6 +68,13 @@
|
||||
/* End PBXFileReference 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 */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
@@ -74,12 +91,20 @@
|
||||
);
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
};
|
||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
ShareViewController.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A6E32C972E762EA70032EA8A /* bitchat */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */,
|
||||
A6E32D1C2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_iOS" target */,
|
||||
A6E32D1D2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchat_macOS" target */,
|
||||
);
|
||||
@@ -88,6 +113,9 @@
|
||||
};
|
||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */,
|
||||
);
|
||||
path = bitchatShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -152,6 +180,7 @@
|
||||
children = (
|
||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */,
|
||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */,
|
||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */,
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
|
||||
);
|
||||
@@ -202,6 +231,19 @@
|
||||
productReference = 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */;
|
||||
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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||
@@ -232,6 +274,9 @@
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A6E32C972E762EA70032EA8A /* bitchat */,
|
||||
);
|
||||
@@ -270,6 +315,7 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
57CA17A36A2532A6CFF367BB /* bitchatShareExtension */,
|
||||
6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */,
|
||||
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */,
|
||||
AF077EA0474EDEDE2C72716C /* bitchat_iOS */,
|
||||
@@ -294,6 +340,11 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
137ABE739BF20ACDDF8CC605 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
files = (
|
||||
@@ -322,6 +373,11 @@
|
||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||
targetProxy = FF470234EF8C6BB8865B80B5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
6EB655BA5DB11909C1DEC460 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
targetProxy = E35E7AF9854A2E72452DD34F /* PBXContainerItemProxy */;
|
||||
};
|
||||
D8C09F21DB7DC06E8E672C21 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AF077EA0474EDEDE2C72716C /* bitchat_iOS */;
|
||||
@@ -406,6 +462,37 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
3DCF45111852FB2AEBE05E31 /* Release configuration for PBXNativeTarget "bitchatShareExtension" */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
baseConfigurationReferenceRelativePath = Release.xcconfig;
|
||||
buildSettings = {
|
||||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).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 = "$(SWIFT_VERSION)";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
702E7395723CADA4B830F4A9 /* Debug configuration for PBXNativeTarget "bitchat_iOS" */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
@@ -417,7 +504,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
@@ -429,7 +516,6 @@
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -478,7 +564,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
@@ -490,7 +576,6 @@
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -515,10 +600,11 @@
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -526,7 +612,6 @@
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -607,10 +692,11 @@
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 66W2RU3J5T;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -618,7 +704,6 @@
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
"PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "chat.bitchat-local";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -693,6 +778,37 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
DAC5E82049F8A97360BE63D6 /* Debug configuration for PBXNativeTarget "bitchatShareExtension" */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||
buildSettings = {
|
||||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).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 = "$(SWIFT_VERSION)";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -736,6 +852,14 @@
|
||||
);
|
||||
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 */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,18 +33,12 @@
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<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>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record and send short voice notes to nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//
|
||||
// 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() } }
|
||||
@@ -21,20 +21,7 @@ struct BitchatPacket: Codable {
|
||||
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
|
||||
@@ -87,7 +74,6 @@ struct BitchatPacket: Codable {
|
||||
// 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,
|
||||
|
||||
@@ -210,6 +210,15 @@ final class NoiseRateLimiter {
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Errors
|
||||
|
||||
@@ -308,6 +308,15 @@ final class NoiseSessionManager {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
|
||||
@@ -150,13 +150,31 @@ struct NostrIdentityBridge {
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
static func clearAllAssociations() {
|
||||
// Delete current Nostr identity
|
||||
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
|
||||
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
|
||||
|
||||
// Note: We can't efficiently delete all noise-nostr associations
|
||||
// without tracking them, but they'll be orphaned and eventually cleaned up
|
||||
// The important part is deleting the current identity so a new one is generated
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
@@ -35,10 +35,12 @@ final class NostrRelayManager: ObservableObject {
|
||||
"wss://nostr21.com"
|
||||
// For local testing, you can add: "ws://localhost:8080"
|
||||
]
|
||||
private static let defaultRelaySet = Set(defaultRelays)
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
|
||||
private var allowDefaultRelays: Bool = false
|
||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||
@@ -65,6 +67,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
@@ -78,29 +82,45 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
// Initialize with default relays
|
||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
allowDefaultRelays = hasMutual
|
||||
if hasMutual {
|
||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
||||
}
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
FavoritesPersistenceService.shared.$mutualFavorites
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] favorites in
|
||||
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Connect to all configured relays
|
||||
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.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor {
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||
for relay in self.relays {
|
||||
connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +140,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
// Global network policy gate
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
guard networkService.activationAllowed else { return }
|
||||
let targets = allowedRelayList(from: relayUrls)
|
||||
guard !targets.isEmpty else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -130,22 +152,21 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
let existing = Set(relays.map { $0.url })
|
||||
for url in Set(relayUrls) {
|
||||
if !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
if connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
}
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in targets where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
for url in targets where connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer sends until Tor is ready to avoid premature queueing
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -154,7 +175,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
@@ -220,7 +243,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// Global network policy gate
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
guard networkService.activationAllowed else { return }
|
||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||
let now = Date()
|
||||
if messageHandlers[id] != nil {
|
||||
@@ -229,7 +252,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -257,32 +280,37 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let urls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
let urls = allowedRelayList(from: candidateUrls)
|
||||
// Always queue subscriptions; sending happens when a relay reports connected
|
||||
let existingSet = Set(relays.map { $0.url })
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
for url in urls {
|
||||
for url in candidateUrls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
}
|
||||
// Initialize EOSE tracking if requested
|
||||
if let onEOSE = onEOSE {
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
if urls.isEmpty {
|
||||
onEOSE()
|
||||
} else {
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
}
|
||||
}
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
||||
@@ -297,6 +325,54 @@ final class NostrRelayManager: ObservableObject {
|
||||
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDefaultRelayPolicy(hasMutual: Bool) {
|
||||
guard hasMutual != allowDefaultRelays else { return }
|
||||
allowDefaultRelays = hasMutual
|
||||
if hasMutual {
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in Self.defaultRelays where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
if networkService.activationAllowed {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
}
|
||||
} else {
|
||||
for url in Self.defaultRelays {
|
||||
if let connection = connections[url] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||
updateConnectionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for url in urls {
|
||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||
if seen.insert(url).inserted {
|
||||
result.append(url)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Unsubscribe from a subscription
|
||||
func unsubscribe(id: String) {
|
||||
@@ -326,14 +402,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
// Global network policy gate
|
||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
||||
guard networkService.activationAllowed else { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -348,7 +424,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
@@ -534,7 +610,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
// If networking is disallowed, do not schedule reconnection
|
||||
if !TorManager.shared.isAutoStartAllowed() {
|
||||
if !networkService.activationAllowed {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
|
||||
@@ -22,12 +22,11 @@
|
||||
///
|
||||
/// ## Wire Format
|
||||
/// ```
|
||||
/// Header (13 bytes for v1, 15 bytes for v2):
|
||||
/// +--------+------+-----+-----------+-------+---------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes (v1) |
|
||||
/// | | | | | | 4 bytes (v2) |
|
||||
/// +--------+------+-----+-----------+-------+---------------+
|
||||
/// Header (Fixed 13 bytes):
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
///
|
||||
/// Variable sections:
|
||||
/// +----------+-------------+---------+------------+
|
||||
@@ -57,10 +56,9 @@
|
||||
/// - Bits 3-7: Reserved for future use
|
||||
///
|
||||
/// ## Size Constraints
|
||||
/// - Maximum packet size: 4.2GB (32-bit length field for v2+)
|
||||
/// - Maximum packet size: 65,535 bytes (16-bit length field)
|
||||
/// - Typical packet size: < 512 bytes (BLE MTU)
|
||||
/// - Minimum packet size: 21 bytes (header + sender ID)
|
||||
/// - Legacy v1 packets: max 65,535 bytes (16-bit length field)
|
||||
///
|
||||
/// ## Encoding Process
|
||||
/// 1. Construct header with fixed fields
|
||||
@@ -107,22 +105,16 @@ extension Data {
|
||||
/// their binary wire format representation.
|
||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||
struct BinaryProtocol {
|
||||
static let headerSizeV1 = 13
|
||||
static let headerSizeV2 = 15
|
||||
static let headerSize = 13
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
|
||||
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
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
|
||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||
@@ -146,10 +138,9 @@ struct BinaryProtocol {
|
||||
} else {
|
||||
}
|
||||
|
||||
// Header with version-dependent size
|
||||
let headerSize = getHeaderSize(packet.version)
|
||||
// Header
|
||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||
// header + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||
data.reserveCapacity(estimated)
|
||||
@@ -175,20 +166,13 @@ struct BinaryProtocol {
|
||||
}
|
||||
data.append(flags)
|
||||
|
||||
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2)
|
||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||
if packet.version >= 2 {
|
||||
// v2+: 4 bytes big-endian
|
||||
data.append(UInt8((payloadDataSize >> 24) & 0xFF))
|
||||
data.append(UInt8((payloadDataSize >> 16) & 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))
|
||||
}
|
||||
let payloadLength = UInt16(payloadDataSize)
|
||||
|
||||
|
||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength & 0xFF))
|
||||
|
||||
// SenderID (exactly 8 bytes)
|
||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||
@@ -243,8 +227,8 @@ struct BinaryProtocol {
|
||||
|
||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||
// Minimum size: smallest header (v1) + senderID
|
||||
guard raw.count >= headerSizeV1 + senderIDSize else { return nil }
|
||||
// Minimum size: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||
|
||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||
guard let base = buf.baseAddress else { return nil }
|
||||
@@ -265,14 +249,6 @@ struct BinaryProtocol {
|
||||
offset += 2
|
||||
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
|
||||
func readData(_ n: Int) -> Data? {
|
||||
guard require(n) else { return nil }
|
||||
@@ -282,8 +258,8 @@ struct BinaryProtocol {
|
||||
return d
|
||||
}
|
||||
|
||||
// Version - accept both v1 and v2+
|
||||
guard let version = read8(), version >= 1 else { return nil }
|
||||
// Version
|
||||
guard let version = read8(), version == 1 else { return nil }
|
||||
guard let type = read8() else { return nil }
|
||||
guard let ttl = read8() else { return nil }
|
||||
|
||||
@@ -301,15 +277,8 @@ struct BinaryProtocol {
|
||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
|
||||
// Payload length - version-dependent (2 bytes for v1, 4 bytes for v2+)
|
||||
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)
|
||||
}
|
||||
// Payload length
|
||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||
|
||||
// SenderID
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
@@ -348,7 +317,6 @@ struct BinaryProtocol {
|
||||
guard offset <= buf.count else { return nil }
|
||||
|
||||
return BitchatPacket(
|
||||
version: version,
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
|
||||
@@ -78,7 +78,6 @@ enum MessageType: UInt8 {
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // File transfer (audio/images) TLV payload
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
@@ -89,7 +88,6 @@ enum MessageType: UInt8 {
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ final class BLEService: NSObject {
|
||||
// MARK: - Constants
|
||||
|
||||
#if DEBUG
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // testnet
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet
|
||||
#else
|
||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
||||
#endif
|
||||
@@ -89,8 +89,9 @@ final class BLEService: NSObject {
|
||||
|
||||
var myPeerID: String = ""
|
||||
var myNickname: String = "anon"
|
||||
private let noiseService: NoiseEncryptionService
|
||||
private var noiseService: NoiseEncryptionService
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var myPeerIDData: Data = Data()
|
||||
|
||||
// MARK: - Advertising Privacy
|
||||
@@ -330,33 +331,44 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
self.identityManager = identityManager
|
||||
super.init()
|
||||
|
||||
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars)
|
||||
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
|
||||
self.myPeerID = String(fingerprint.prefix(16))
|
||||
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||
|
||||
// Set queue key for identification
|
||||
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
||||
|
||||
// Set up Noise session establishment callback
|
||||
// This ensures we send pending messages only when session is truly established
|
||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
|
||||
// Send any messages that were queued during handshake
|
||||
self?.messageQueue.async { [weak self] in
|
||||
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
||||
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
||||
}
|
||||
// Proactive presence nudge: announce immediately after handshake
|
||||
self?.messageQueue.async { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPeerIdentity() {
|
||||
let fingerprint = noiseService.getIdentityFingerprint()
|
||||
myPeerID = String(fingerprint.prefix(16))
|
||||
myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||
}
|
||||
|
||||
private func restartGossipManager() {
|
||||
gossipSyncManager?.stop()
|
||||
let sync = GossipSyncManager(myPeerID: myPeerID)
|
||||
sync.delegate = self
|
||||
sync.start()
|
||||
gossipSyncManager = sync
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
self.identityManager = identityManager
|
||||
super.init()
|
||||
|
||||
configureNoiseServiceCallbacks(for: noiseService)
|
||||
refreshPeerIdentity()
|
||||
|
||||
// Set queue key for identification
|
||||
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
||||
|
||||
// Set up application state tracking (iOS only)
|
||||
#if os(iOS)
|
||||
@@ -407,10 +419,7 @@ final class BLEService: NSObject {
|
||||
requestPeerDataPublish()
|
||||
|
||||
// Initialize gossip sync manager
|
||||
let sync = GossipSyncManager(myPeerID: myPeerID)
|
||||
sync.delegate = self
|
||||
sync.start()
|
||||
self.gossipSyncManager = sync
|
||||
restartGossipManager()
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
@@ -738,6 +747,46 @@ final class BLEService: NSObject {
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
|
||||
func resetIdentityForPanic(currentNickname: String) {
|
||||
messageQueue.sync(flags: .barrier) {
|
||||
pendingMessagesAfterHandshake.removeAll()
|
||||
pendingNoisePayloadsAfterHandshake.removeAll()
|
||||
}
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
recentAnnounceBySender.removeAll()
|
||||
recentAnnounceOrder.removeAll()
|
||||
pendingPeripheralWrites.removeAll()
|
||||
pendingNotifications.removeAll()
|
||||
pendingDirectedRelays.removeAll()
|
||||
ingressByMessageID.removeAll()
|
||||
recentPacketTimestamps.removeAll()
|
||||
scheduledRelays.values.forEach { $0.cancel() }
|
||||
scheduledRelays.removeAll()
|
||||
}
|
||||
|
||||
bleQueue.sync {
|
||||
pendingWriteBuffers.removeAll()
|
||||
recentConnectTimeouts.removeAll()
|
||||
}
|
||||
recentDisconnectNotifies.removeAll()
|
||||
|
||||
noiseService.clearEphemeralStateForPanic()
|
||||
noiseService.clearPersistentIdentity()
|
||||
|
||||
let newNoise = NoiseEncryptionService(keychain: keychain)
|
||||
noiseService = newNoise
|
||||
configureNoiseServiceCallbacks(for: newNoise)
|
||||
refreshPeerIdentity()
|
||||
restartGossipManager()
|
||||
|
||||
setNickname(currentNickname)
|
||||
|
||||
messageDeduplicator.reset()
|
||||
requestPeerDataPublish()
|
||||
startServices()
|
||||
}
|
||||
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
return noiseService
|
||||
@@ -1276,127 +1325,6 @@ 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) {
|
||||
// Don't process our own fragments
|
||||
@@ -1539,9 +1467,6 @@ final class BLEService: NSObject {
|
||||
|
||||
case .fragment:
|
||||
handleFragment(packet, from: senderID)
|
||||
|
||||
case .fileTransfer:
|
||||
handleFileTransfer(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
@@ -1584,72 +1509,6 @@ final class BLEService: NSObject {
|
||||
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) {
|
||||
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||
|
||||
@@ -282,6 +282,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
"com.bitchat.deviceidentity",
|
||||
"com.bitchat.noise.identity",
|
||||
"chat.bitchat.passwords",
|
||||
"chat.bitchat.nostr",
|
||||
"bitchat.keychain",
|
||||
"bitchat",
|
||||
"com.bitchat"
|
||||
|
||||
@@ -10,9 +10,12 @@ final class NetworkActivationService: ObservableObject {
|
||||
static let shared = NetworkActivationService()
|
||||
|
||||
@Published private(set) var activationAllowed: Bool = false
|
||||
@Published private(set) var userTorEnabled: Bool = true
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var started = false
|
||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
private var torAutoStartDesired: Bool = false
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -20,9 +23,23 @@ final class NetworkActivationService: ObservableObject {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||
userTorEnabled = stored
|
||||
} else {
|
||||
userTorEnabled = true
|
||||
}
|
||||
|
||||
// Initial compute
|
||||
activationAllowed = Self.computeAllowed()
|
||||
TorManager.shared.setAutoStartAllowed(activationAllowed)
|
||||
let allowed = basePolicyAllowed()
|
||||
activationAllowed = allowed
|
||||
torAutoStartDesired = allowed && userTorEnabled
|
||||
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||
applyTorState(torDesired: torAutoStartDesired)
|
||||
if allowed {
|
||||
NostrRelayManager.shared.connect()
|
||||
} else {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
|
||||
// React to location permission changes
|
||||
LocationChannelManager.shared.$permissionState
|
||||
@@ -41,30 +58,56 @@ final class NetworkActivationService: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
guard enabled != userTorEnabled else { return }
|
||||
userTorEnabled = enabled
|
||||
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||
NotificationCenter.default.post(
|
||||
name: .TorUserPreferenceChanged,
|
||||
object: nil,
|
||||
userInfo: ["enabled": enabled]
|
||||
)
|
||||
reevaluate()
|
||||
}
|
||||
|
||||
private func reevaluate() {
|
||||
let allowed = Self.computeAllowed()
|
||||
if allowed != activationAllowed {
|
||||
let allowed = basePolicyAllowed()
|
||||
let torDesired = allowed && userTorEnabled
|
||||
let statusChanged = allowed != activationAllowed
|
||||
let torChanged = torDesired != torAutoStartDesired
|
||||
if statusChanged {
|
||||
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
|
||||
}
|
||||
if statusChanged || torChanged {
|
||||
torAutoStartDesired = torDesired
|
||||
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||
applyTorState(torDesired: torDesired)
|
||||
}
|
||||
|
||||
if allowed {
|
||||
if torChanged {
|
||||
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||
NostrRelayManager.shared.disconnect()
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
}
|
||||
NostrRelayManager.shared.connect()
|
||||
} else if statusChanged {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private static func computeAllowed() -> Bool {
|
||||
private func basePolicyAllowed() -> Bool {
|
||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
return permOK || hasMutual
|
||||
}
|
||||
|
||||
private func applyTorState(torDesired: Bool) {
|
||||
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||
if torDesired {
|
||||
TorManager.shared.startIfNeeded()
|
||||
} else {
|
||||
TorManager.shared.shutdownCompletely()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +526,15 @@ final class NoiseEncryptionService {
|
||||
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
|
||||
func clearEphemeralStateForPanic() {
|
||||
sessionManager.removeAllSessions()
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
peerFingerprints.removeAll()
|
||||
fingerprintToPeerID.removeAll()
|
||||
}
|
||||
rateLimiter.resetAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
|
||||
@@ -4,10 +4,6 @@ import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport {
|
||||
func sendFileTransferTLV(_ payload: Data, recipientPeerID: String?, transferId: String, messageID: String) {
|
||||
return
|
||||
}
|
||||
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
|
||||
@@ -68,6 +68,7 @@ final class TorManager: ObservableObject {
|
||||
private var controlMonitorStarted = false
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
private var isAppForeground: Bool = true
|
||||
private var isDormant: Bool = false
|
||||
private var lastRestartAt: Date? = nil
|
||||
// Global policy gate: only allow Tor to start when true
|
||||
private(set) var allowAutoStart: Bool = false
|
||||
@@ -83,6 +84,7 @@ final class TorManager: ObservableObject {
|
||||
guard isAppForeground else { return }
|
||||
guard !didStart else { return }
|
||||
didStart = true
|
||||
isDormant = false
|
||||
isStarting = true
|
||||
lastError = nil
|
||||
// Announce initial start so UI can show a status message
|
||||
@@ -492,18 +494,39 @@ final class TorManager: ObservableObject {
|
||||
return true
|
||||
}
|
||||
if !claimed { return }
|
||||
if await self.resumeTorIfPossible() {
|
||||
await MainActor.run {
|
||||
self.restarting = false
|
||||
self.isStarting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await self.restartTor()
|
||||
await MainActor.run { self.restarting = false }
|
||||
}
|
||||
}
|
||||
|
||||
func goDormantOnBackground() {
|
||||
// Stricter model: fully stop Tor when app backgrounds to save power
|
||||
// and avoid half-suspended states. We'll restart cleanly on .active.
|
||||
// Prefer Tor's DORMANT mode so we can resume on foreground without a full restart.
|
||||
// If the control port is unreachable, fall back to a hard shutdown.
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let signaled = await self.controlSendSignal("DORMANT")
|
||||
if signaled {
|
||||
SecureLogger.info("TorManager: signalled DORMANT", category: .session)
|
||||
await MainActor.run {
|
||||
self.isDormant = true
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.isStarting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.warning("TorManager: DORMANT signal failed; shutting down", category: .session)
|
||||
_ = tor_host_shutdown()
|
||||
await MainActor.run {
|
||||
self.isDormant = false
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.bootstrapProgress = 0
|
||||
@@ -516,6 +539,24 @@ final class TorManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownCompletely() {
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
_ = tor_host_shutdown()
|
||||
await MainActor.run {
|
||||
self.isDormant = false
|
||||
self.isReady = false
|
||||
self.socksReady = false
|
||||
self.bootstrapProgress = 0
|
||||
self.bootstrapSummary = ""
|
||||
self.isStarting = false
|
||||
self.didStart = false
|
||||
self.restarting = false
|
||||
self.controlMonitorStarted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restartTor() async {
|
||||
await MainActor.run {
|
||||
// Announce restart so UI can notify the user
|
||||
@@ -525,14 +566,28 @@ final class TorManager: ObservableObject {
|
||||
self.bootstrapProgress = 0
|
||||
self.bootstrapSummary = ""
|
||||
self.isStarting = true
|
||||
self.isDormant = false
|
||||
self.lastRestartAt = Date()
|
||||
}
|
||||
// Prefer clean shutdown via owning controller FD; join the tor thread
|
||||
_ = tor_host_shutdown()
|
||||
// As a fallback, try control signal if needed (harmless if tor already down)
|
||||
_ = await controlSendSignal("SHUTDOWN")
|
||||
// Allow Tor thread to fully terminate before re-starting.
|
||||
var waited = 0
|
||||
while tor_host_is_running() != 0 && waited < 40 {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
waited += 1
|
||||
}
|
||||
if waited >= 40 {
|
||||
SecureLogger.warning("TorManager: tor_host_is_running still true before restart", category: .session)
|
||||
}
|
||||
// Allow control monitor and start logic to reinitialize cleanly
|
||||
await MainActor.run {
|
||||
self.controlMonitorStarted = false
|
||||
self.didStart = false
|
||||
}
|
||||
// Now start fresh
|
||||
await MainActor.run { self.didStart = false }
|
||||
await MainActor.run { self.startIfNeeded() }
|
||||
}
|
||||
|
||||
@@ -598,6 +653,62 @@ final class TorManager: ObservableObject {
|
||||
return (text?.contains("250")) == true
|
||||
}
|
||||
|
||||
private func resumeTorIfPossible() async -> Bool {
|
||||
let wasDormant = await MainActor.run { self.isDormant }
|
||||
let pendingReady = await MainActor.run { self.socksReady && !self.isReady }
|
||||
let needsWake = wasDormant || pendingReady
|
||||
if !needsWake {
|
||||
return false
|
||||
}
|
||||
|
||||
let activated = await controlSendSignal("ACTIVE")
|
||||
let pinged = await controlPingBootstrap(timeout: 3.0)
|
||||
if !activated && !pinged {
|
||||
SecureLogger.warning("TorManager: ACTIVE signal failed", category: .session)
|
||||
return false
|
||||
}
|
||||
|
||||
if let info = await controlGetBootstrapInfo() {
|
||||
await MainActor.run {
|
||||
self.bootstrapProgress = info.progress
|
||||
self.bootstrapSummary = info.summary
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.isDormant = false
|
||||
self.isStarting = true
|
||||
self.socksReady = false
|
||||
}
|
||||
|
||||
let firstReady = await waitForSocksReady(timeout: 12.0)
|
||||
if firstReady {
|
||||
await MainActor.run {
|
||||
self.socksReady = true
|
||||
self.isStarting = false
|
||||
}
|
||||
SecureLogger.info("TorManager: resumed Tor via ACTIVE signal", category: .session)
|
||||
return true
|
||||
}
|
||||
|
||||
if pinged {
|
||||
let secondReady = await waitForSocksReady(timeout: 20.0)
|
||||
await MainActor.run {
|
||||
self.socksReady = secondReady
|
||||
self.isStarting = !secondReady
|
||||
}
|
||||
if secondReady {
|
||||
SecureLogger.info("TorManager: resumed Tor after extended wait", category: .session)
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
await MainActor.run { self.isStarting = false }
|
||||
}
|
||||
|
||||
SecureLogger.warning("TorManager: ACTIVE resume failed; will restart", category: .session)
|
||||
return false
|
||||
}
|
||||
|
||||
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
|
||||
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
|
||||
let cookie = try? Data(contentsOf: cookiePath) else { return nil }
|
||||
|
||||
@@ -4,4 +4,5 @@ extension Notification.Name {
|
||||
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
||||
static let TorWillRestart = Notification.Name("TorWillRestart")
|
||||
static let TorWillStart = Notification.Name("TorWillStart")
|
||||
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
|
||||
}
|
||||
|
||||
@@ -4,43 +4,34 @@ import CFNetwork
|
||||
#endif
|
||||
|
||||
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
|
||||
/// when Tor is enforced/ready. Falls back to a default session only when
|
||||
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
|
||||
/// when Tor is enforced/ready. Allows swapping between proxied and direct
|
||||
/// sessions so UI can toggle Tor usage at runtime.
|
||||
final class TorURLSession {
|
||||
static let shared = TorURLSession()
|
||||
|
||||
// Default (no proxy) session for local development when dev bypass is enabled.
|
||||
private var defaultSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
// Default (no proxy) session for direct Nostr access when Tor is disabled.
|
||||
private var defaultSession: URLSession = TorURLSession.makeDefaultSession()
|
||||
|
||||
// Proxied (SOCKS5) session that routes through Tor.
|
||||
private var torSession: URLSession = TorURLSession.makeTorSession()
|
||||
private var useTorProxy: Bool = true
|
||||
|
||||
var session: URLSession {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
||||
return defaultSession
|
||||
#else
|
||||
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
||||
return torSession
|
||||
#endif
|
||||
useTorProxy ? torSession : defaultSession
|
||||
}
|
||||
|
||||
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
||||
func rebuild() {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
defaultSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
#endif
|
||||
defaultSession = TorURLSession.makeDefaultSession()
|
||||
torSession = TorURLSession.makeTorSession()
|
||||
}
|
||||
|
||||
func setProxyMode(useTor: Bool) {
|
||||
guard useTorProxy != useTor else { return }
|
||||
useTorProxy = useTor
|
||||
rebuild()
|
||||
}
|
||||
|
||||
private static func makeTorSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.waitsForConnectivity = true
|
||||
@@ -63,4 +54,10 @@ final class TorURLSession {
|
||||
#endif
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
private static func makeDefaultSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.waitsForConnectivity = true
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,6 @@ protocol Transport: AnyObject {
|
||||
func sendBroadcastAnnounce()
|
||||
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)
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// 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.
|
||||
@@ -1,113 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -86,9 +86,6 @@ import CommonCrypto
|
||||
import CoreBluetooth
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#if canImport(PhotosUI)
|
||||
import PhotosUI
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/// Manages the application state and business logic for BitChat.
|
||||
@@ -781,6 +778,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
name: .TorWillStart,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleTorPreferenceChanged(_:)),
|
||||
name: .TorUserPreferenceChanged,
|
||||
object: nil
|
||||
)
|
||||
#else
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
@@ -831,6 +834,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
name: .TorWillStart,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleTorPreferenceChanged(_:)),
|
||||
name: .TorUserPreferenceChanged,
|
||||
object: nil
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -872,6 +881,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleTorPreferenceChanged(_ notification: Notification) {
|
||||
Task { @MainActor in
|
||||
self.torStatusAnnounced = false
|
||||
self.torInitialReadyAnnounced = false
|
||||
self.torRestartPending = false
|
||||
}
|
||||
}
|
||||
|
||||
// Resubscribe to the active geohash channel without clearing timeline
|
||||
@MainActor
|
||||
@@ -1470,7 +1487,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private func sendGeohash(ch: GeohashChannel, content: String) {
|
||||
do {
|
||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
|
||||
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: ch.geohash,
|
||||
@@ -1489,9 +1506,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
|
||||
// Track ourselves as active participant
|
||||
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
let shortKey = "nostr:" + identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
|
||||
nostrKeyMapping[shortKey] = identity.publicKeyHex
|
||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session)
|
||||
|
||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||
@@ -1887,6 +1906,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
geohashPeople = people
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isSelfSender(peerID: String?, displayName: String?) -> Bool {
|
||||
guard let peerID else { return false }
|
||||
if peerID == meshService.myPeerID { return true }
|
||||
let lowerPeer = peerID.lowercased()
|
||||
guard lowerPeer.hasPrefix("nostr") else { return false }
|
||||
|
||||
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
|
||||
let gh = currentGeohash,
|
||||
let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||
if mapped == myIdentity.publicKeyHex.lowercased() { return true }
|
||||
}
|
||||
|
||||
if let gh = currentGeohash,
|
||||
let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||
let myLower = myIdentity.publicKeyHex.lowercased()
|
||||
let shortLen = TransportConfig.nostrShortKeyDisplayLength
|
||||
let shortKey = "nostr:" + myLower.prefix(shortLen)
|
||||
if lowerPeer == shortKey { return true }
|
||||
let suffix = myIdentity.publicKeyHex.suffix(4)
|
||||
let expected = (nickname + "#" + suffix).lowercased()
|
||||
if let display = displayName?.lowercased(), display == expected { return true }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func startGeoParticipantsTimer() {
|
||||
stopGeoParticipantsTimer()
|
||||
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
|
||||
@@ -3091,8 +3137,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Clear persistent favorites from keychain
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
|
||||
// Clear identity data from secure storage
|
||||
identityManager.clearAllIdentityData()
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
// Clear autocomplete state
|
||||
autocompleteSuggestions.removeAll()
|
||||
@@ -3122,6 +3167,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Disconnect from all peers and clear persistent identity
|
||||
// This will force creation of a new identity (new fingerprint) on next launch
|
||||
meshService.emergencyDisconnectAll()
|
||||
if let bleService = meshService as? BLEService {
|
||||
bleService.resetIdentityForPanic(currentNickname: nickname)
|
||||
}
|
||||
|
||||
// No need to force UserDefaults synchronization
|
||||
|
||||
@@ -3771,7 +3819,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
if let spid = message.senderPeerID {
|
||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||
let bare: String = {
|
||||
@@ -6039,7 +6087,6 @@ private func checkForMentions(_ message: BitchatMessage) {
|
||||
impactFeedback.prepare()
|
||||
|
||||
for i in 0..<8 {
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) {
|
||||
impactFeedback.impactOccurred()
|
||||
}
|
||||
@@ -6052,67 +6099,5 @@ private func checkForMentions(_ message: BitchatMessage) {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Voice Notes (Audio)
|
||||
@MainActor
|
||||
func sendVoiceNote(fileURL: URL) {
|
||||
// Read file data
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return }
|
||||
let name = fileURL.lastPathComponent
|
||||
let mime = "audio/mp4"
|
||||
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 = "[voice] \(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(peerID: peer, message: msg)
|
||||
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
|
||||
)
|
||||
publicBuffer.append(msg)
|
||||
schedulePublicFlush()
|
||||
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
// Images code moved to ChatViewModel+Images.swift
|
||||
|
||||
@MainActor
|
||||
private func appendToPrivateChat(peerID: String, message: BitchatMessage) {
|
||||
var arr = privateChats[peerID] ?? []
|
||||
arr.append(message)
|
||||
privateChats[peerID] = arr
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
// End of ChatViewModel class
|
||||
|
||||
+94
-292
@@ -9,7 +9,6 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
import PhotosUI
|
||||
#endif
|
||||
|
||||
// MARK: - Supporting Types
|
||||
@@ -58,21 +57,6 @@ struct ContentView: View {
|
||||
// Window sizes for rendering (infinite scroll up)
|
||||
@State private var windowCountPublic: Int = 300
|
||||
@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
|
||||
|
||||
@@ -268,16 +252,6 @@ struct ContentView: View {
|
||||
scrollThrottleTimer?.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
|
||||
@@ -331,57 +305,20 @@ struct ContentView: View {
|
||||
// Precompute heavy token scans once per row
|
||||
let cashuTokens = message.content.extractCashuTokens()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
let isVoice = message.content.hasPrefix("[voice] ")
|
||||
let isImage = message.content.hasPrefix("[image] ")
|
||||
|
||||
if isVoice {
|
||||
// Header line above media: sender + timestamp
|
||||
MessageHeaderLine(message: message)
|
||||
.environmentObject(viewModel)
|
||||
// Hide the raw marker text; render custom voice view
|
||||
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
|
||||
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)
|
||||
}
|
||||
} 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
|
||||
@@ -525,7 +462,12 @@ struct ContentView: View {
|
||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
}
|
||||
}
|
||||
showMessageActions = true
|
||||
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
|
||||
selectedMessageSender = nil
|
||||
selectedMessageSenderID = nil
|
||||
} else {
|
||||
showMessageActions = true
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard url.scheme == "bitchat", url.host == "geohash" else { return }
|
||||
@@ -858,171 +800,92 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ZStack(alignment: .leading) {
|
||||
// Always keep the TextField mounted so the keyboard state doesn't change
|
||||
TextField("type a message...", text: $messageText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.focused($isTextFieldFocused)
|
||||
.padding(.leading, 12)
|
||||
.opacity(isRecordingVoice ? 0.0 : 1.0)
|
||||
// Measure the text field height so we can match recorder height
|
||||
.background(GeometryReader { geo in
|
||||
Color.clear.preference(key: InputHeightPreferenceKey.self, value: geo.size.height)
|
||||
})
|
||||
|
||||
// Recording overlay: live waveform + elapsed/max time, same height as input field
|
||||
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)
|
||||
TextField("type a message...", text: $messageText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.focused($isTextFieldFocused)
|
||||
.padding(.leading, 12)
|
||||
// iOS keyboard autocomplete and capitalization enabled by default
|
||||
.onChange(of: messageText) { newValue in
|
||||
// Cancel previous debounce timer
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.frame(height: max(28, inputFieldMeasuredHeight))
|
||||
}
|
||||
|
||||
}
|
||||
.onChange(of: messageText) { newValue in
|
||||
// Cancel previous debounce timer
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
let isGeoPublic: Bool = {
|
||||
if case .location = locationManager.selectedChannel { return true }
|
||||
return false
|
||||
}()
|
||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
var commandDescriptions = [
|
||||
("/block", "block or list blocked peers"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/hug", "send someone a warm hug"),
|
||||
("/m", "send private message"),
|
||||
("/slap", "slap someone with a trout"),
|
||||
("/unblock", "unblock a peer"),
|
||||
("/w", "see who's online")
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", "add to favorites"))
|
||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
// 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)
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
let isGeoPublic: Bool = {
|
||||
if case .location = locationManager.selectedChannel { return true }
|
||||
return false
|
||||
}()
|
||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
var commandDescriptions = [
|
||||
("/block", "block or list blocked peers"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/hug", "send someone a warm hug"),
|
||||
("/m", "send private message"),
|
||||
("/slap", "slap someone with a trout"),
|
||||
("/unblock", "unblock a peer"),
|
||||
("/w", "see who's online")
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", "add to favorites"))
|
||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
// 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 }
|
||||
.onSubmit {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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)
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
@@ -1042,67 +905,6 @@ struct ContentView: View {
|
||||
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
|
||||
|
||||
private var sidebarView: some View {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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
|
||||
@@ -9,6 +9,7 @@ struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@ObservedObject private var network = NetworkActivationService.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var customGeohash: String = ""
|
||||
@@ -266,6 +267,7 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
torToggleSection
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
@@ -409,8 +411,36 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Standardized Colors
|
||||
// MARK: - TOR Toggle & Standardized Colors
|
||||
extension LocationChannelsSheet {
|
||||
private var torToggleBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { network.userTorEnabled },
|
||||
set: { network.setUserTorEnabled($0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var torToggleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: torToggleBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("tor routing")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Text("hides your ip for location channels. recommended: on.")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(IRCToggleStyle(accent: standardGreen))
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private var standardGreen: Color {
|
||||
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
@@ -419,6 +449,34 @@ extension LocationChannelsSheet {
|
||||
}
|
||||
}
|
||||
|
||||
private struct IRCToggleStyle: ToggleStyle {
|
||||
let accent: Color
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button(action: { configuration.isOn.toggle() }) {
|
||||
HStack(spacing: 12) {
|
||||
configuration.label
|
||||
Spacer()
|
||||
Text(configuration.isOn ? "on" : "off")
|
||||
.textCase(.uppercase)
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coverage helpers
|
||||
extension LocationChannelsSheet {
|
||||
private func coverageString(forPrecision len: Int) -> String {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat-local</string>
|
||||
<string>group.chat.bitchat</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat-local</string>
|
||||
<string>group.chat.bitchat</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user