mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:45:20 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef010dff37 | ||
|
|
8fbfa359ff | ||
|
|
eeb4d55476 | ||
|
|
4882b99752 | ||
|
|
9b8498c78e | ||
|
|
1cf8449e22 | ||
|
|
7ca1ff0a7e | ||
|
|
7316a4a46d | ||
|
|
e992d96939 | ||
|
|
e427e1c62f | ||
|
|
fae76dc515 | ||
|
|
76f976438f | ||
|
|
369c335088 | ||
|
|
a84b8c05f1 | ||
|
|
9d738cff45 | ||
|
|
9879cb2580 | ||
|
|
e1bd8a69c0 | ||
|
|
629bea7fd0 | ||
|
|
1cf4ea9e57 | ||
|
|
3b96d8590d | ||
|
|
6cbfe8ecfb | ||
|
|
8481e1552d | ||
|
|
9ebaa9108c | ||
|
|
2b3264b581 | ||
|
|
830f7ee4b8 | ||
|
|
c8768c32b8 | ||
|
|
1352484491 | ||
|
|
ef396936a3 | ||
|
|
c3ce32ad07 | ||
|
|
d7ca99ae5d | ||
|
|
b25f0d9f63 | ||
|
|
abb998d809 | ||
|
|
d71befd8b7 | ||
|
|
78c4bed1ad | ||
|
|
c52a2a7772 | ||
|
|
3df0aa0cc4 | ||
|
|
46caded099 | ||
|
|
a04ac9dabd | ||
|
|
1825d115bc | ||
|
|
9754881af8 | ||
|
|
59cc857fde | ||
|
|
f0ca1b27c2 | ||
|
|
5beff8b4dc | ||
|
|
ba49b885bb | ||
|
|
ac10e906c0 | ||
|
|
119e882574 | ||
|
|
0acbbdf2e3 | ||
|
|
40fe0c8ba5 | ||
|
|
7593053137 | ||
|
|
4dde063027 | ||
|
|
8c4fead945 | ||
|
|
2543277755 | ||
|
|
a8ce4cbf64 | ||
|
|
93aa714ed4 | ||
|
|
9c7bf26e13 | ||
|
|
c29c7d83fe | ||
|
|
7f743d48fe | ||
|
|
27c39332d5 | ||
|
|
53c08d6807 | ||
|
|
7efe0a23b1 | ||
|
|
8dedc81512 | ||
|
|
5cbe9d4aa7 | ||
|
|
515fae9e25 | ||
|
|
0946d3921f | ||
|
|
1d40950118 | ||
|
|
b404d48ba6 | ||
|
|
2f1ba27d51 | ||
|
|
23124b36b7 | ||
|
|
3e8d05fa2e | ||
|
|
c36b582209 | ||
|
|
3c66ca7499 | ||
|
|
a042576652 | ||
|
|
3c120042a4 | ||
|
|
4e3603d5e4 | ||
|
|
f7ad970a83 | ||
|
|
4cc87633e1 | ||
|
|
2d565ad918 | ||
|
|
b4cb4d36f1 | ||
|
|
7fcef2a9cb | ||
|
|
e7dc0e0c5a | ||
|
|
3b2c7d2f73 | ||
|
|
209f926990 | ||
|
|
ef8b509ea5 | ||
|
|
06b5bdb6da | ||
|
|
a5a3efebcb | ||
|
|
cd39d8c8c0 | ||
|
|
1046bfec0f | ||
|
|
d8c83e25f4 | ||
|
|
a9fa614416 | ||
|
|
ecbbc23862 |
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-relay-data:
|
||||
@@ -18,54 +17,24 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Configure git
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git config user.email "action@github.com"
|
||||
git config user.name "GitHub Action"
|
||||
|
||||
- name: Create update branch if changes
|
||||
id: create_branch
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
# exit early if no changes
|
||||
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# branch name with timestamp
|
||||
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Push branch
|
||||
if: steps.create_branch.outputs.changed == 'true'
|
||||
run: |
|
||||
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.create_branch.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: Automated update of relay data
|
||||
branch: ${{ steps.create_branch.outputs.branch }}
|
||||
base: main
|
||||
title: Automated update of relay data
|
||||
body: |
|
||||
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
||||
labels: automated, georelays
|
||||
|
||||
- name: No changes
|
||||
if: steps.create_branch.outputs.changed != 'true'
|
||||
run: echo "No changes to relays/online_relays_gps.csv"
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -98,8 +98,8 @@
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
|
||||
@@ -246,7 +246,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let compressionQuality: CGFloat = 0.85
|
||||
private static let targetImageBytes: Int = 60_000
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||
// Security H1: Check file size BEFORE reading into memory
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int else {
|
||||
@@ -38,7 +38,7 @@ enum ImageUtils {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
@@ -106,7 +106,7 @@ enum ImageUtils {
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
|
||||
@@ -14,7 +14,6 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
@@ -76,14 +75,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
AVEncoderBitRateKey: 20_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.delegate = self
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
audioRecorder.record()
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
|
||||
@@ -6268,6 +6268,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Choose an image" : {
|
||||
"comment" : "A label displayed above a button that allows the user to choose an image to send.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"close" : {
|
||||
"comment" : "Button to dismiss fullscreen media viewer",
|
||||
"localizations" : {
|
||||
@@ -35701,545 +35705,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Voice notes are only available in mesh chats." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "الملاحظات الصوتية متاحة فقط في محادثات الميش."
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ভয়েস নোট শুধু মেশ চ্যাটে উপলব্ধ।"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sprachnachrichten sind nur im Mesh-Chat verfügbar."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Voice notes are only available in mesh chats."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Las notas de voz solo están disponibles en los chats de mesh."
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ang mga voice note ay available lamang sa mga mesh chat."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Les notes vocales sont uniquement disponibles dans les discussions mesh."
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "הערות קוליות זמינות רק בצ׳אט של mesh."
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "वॉइस नोट्स केवल मेश चैट में ही उपलब्ध हैं।"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Catatan suara hanya tersedia di obrolan mesh."
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Le note vocali sono disponibili solo nelle chat mesh."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ボイスメモはメッシュチャットでのみ利用できます。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "음성 메모는 메쉬 채팅에서만 사용할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nota suara hanya tersedia dalam sembang mesh."
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "भ्वाइस नोटहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Spraaknotities zijn alleen beschikbaar in mesh-chats."
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Notatki głosowe są dostępne tylko na czatach mesh."
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "As notas de voz só estão disponíveis nos chats mesh."
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "As mensagens de voz só estão disponíveis nos chats mesh."
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Голосовые сообщения доступны только в mesh-чатах."
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Röstanteckningar är bara tillgängliga i mesh-chattar."
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "குரல் குறிப்புகள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்."
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "บันทึกเสียงใช้งานได้เฉพาะในแชต mesh เท่านั้น"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sesli notlar yalnızca mesh sohbetlerinde kullanılabilir."
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Голосові нотатки доступні лише в mesh-чатах."
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "وائس نوٹس صرف میش چیٹس میں دستیاب ہیں۔"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ghi chú giọng nói chỉ khả dụng trong các cuộc trò chuyện mesh."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "语音消息仅可在 mesh 聊天中使用。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "語音訊息僅能在 mesh 聊天中使用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Images are only available in mesh chats." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "الصور متاحة فقط في محادثات الميش."
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bilder sind nur im Mesh-Chat verfügbar."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Images are only available in mesh chats."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Las imágenes solo están disponibles en los chats de mesh."
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ang mga larawan ay available lamang sa mga mesh chat."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Les images sont uniquement disponibles dans les discussions mesh."
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "תמונות זמינות רק בצ׳אט של mesh."
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Gambar hanya tersedia di obrolan mesh."
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Le immagini sono disponibili solo nelle chat mesh."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "画像はメッシュチャットでのみ利用できます。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Imej hanya tersedia dalam sembang mesh."
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats."
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Obrazy są dostępne tylko na czatach mesh."
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "As imagens só estão disponíveis nos chats mesh."
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "As imagens só estão disponíveis nos chats mesh."
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Изображения доступны только в mesh-чатах."
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bilder är bara tillgängliga i mesh-chattar."
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்."
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir."
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Зображення доступні лише в mesh-чатах."
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "图片仅可在 mesh 聊天中使用。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "圖片僅能在 mesh 聊天中使用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Choose an image" : {
|
||||
"comment" : "A label displayed above a button that allows the user to choose an image to send.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "اختر صورة"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "একটি ছবি নির্বাচন করুন"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bild auswählen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Choose an image"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Elige una imagen"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pumili ng larawan"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Choisir une image"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "בחר תמונה"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "एक चित्र चुनें"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pilih gambar"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Scegli un’immagine"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "画像を選択"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "이미지를 선택하세요"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pilih imej"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "एउटा तस्वीर चयन गर्नुहोस्"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kies een afbeelding"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Wybierz obraz"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Escolher uma imagem"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Escolha uma imagem"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Выберите изображение"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Välj en bild"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "เลือกภาพ"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bir görüntü seç"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Виберіть зображення"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ایک تصویر منتخب کریں"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Chọn một hình ảnh"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "选择图像"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "選擇圖像"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
|
||||
// Private so the callers have to go through a convenience init
|
||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||
self.prefix = prefix
|
||||
self.bare = String(bare).lowercased()
|
||||
self.bare = String(bare)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,12 +76,6 @@ extension PeerID {
|
||||
init(hexData: Data) {
|
||||
self.init(str: hexData.hexEncodedString())
|
||||
}
|
||||
|
||||
/// Convenience init to "hide" hex-encoding implementation detail
|
||||
init?(hexData: Data?) {
|
||||
guard let hexData else { return nil }
|
||||
self.init(hexData: hexData)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Public Key Helpers
|
||||
@@ -197,7 +191,9 @@ extension PeerID: Comparable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
// MARK: - String Interop Helpers
|
||||
|
||||
// MARK: CustomStringConvertible
|
||||
|
||||
extension PeerID: CustomStringConvertible {
|
||||
/// So it returns the actual `id` like before even inside another String
|
||||
@@ -205,3 +201,17 @@ extension PeerID: CustomStringConvertible {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Custom Equatable w/ String & Optionality
|
||||
|
||||
// PeerID <> String
|
||||
extension Optional where Wrapped == PeerID {
|
||||
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||
}
|
||||
|
||||
// String <> PeerID
|
||||
extension Optional where Wrapped == String {
|
||||
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import Foundation
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
let receiptID: String
|
||||
var readerID: PeerID // Who read it
|
||||
var readerID: String // Who read it
|
||||
let readerNickname: String
|
||||
let timestamp: Date
|
||||
|
||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = UUID().uuidString
|
||||
self.readerID = readerID
|
||||
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
|
||||
}
|
||||
|
||||
// For binary decoding
|
||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = receiptID
|
||||
self.readerID = readerID
|
||||
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
|
||||
data.appendUUID(receiptID)
|
||||
// ReaderID as 8-byte hex string
|
||||
var readerData = Data()
|
||||
var tempID = readerID.id
|
||||
var tempID = readerID
|
||||
while tempID.count >= 2 && readerData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
|
||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = PeerID(hexData: readerIDData)
|
||||
guard readerID.isValid else { return nil }
|
||||
let readerID = readerIDData.hexEncodedString()
|
||||
guard PeerID(str: readerID).isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
|
||||
@@ -8,14 +8,6 @@ struct RequestSyncPacket {
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
@@ -33,9 +25,6 @@ struct RequestSyncPacket {
|
||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -44,7 +33,6 @@ struct RequestSyncPacket {
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
@@ -64,16 +52,12 @@ struct RequestSyncPacket {
|
||||
case 0x03:
|
||||
if v.count > maxAcceptBytes { return nil }
|
||||
payload = v
|
||||
case 0x04:
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
@MainActor
|
||||
@@ -17,32 +12,19 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
static let shared = GeoRelayDirectory()
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
private let cacheFileName = "georelays_cache.csv"
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||
|
||||
private var refreshTimer: Timer?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||
|
||||
private init() {
|
||||
entries = loadLocalEntries()
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
// Load cached or bundled data synchronously
|
||||
self.entries = self.loadLocalEntries()
|
||||
// Fire-and-forget remote refresh if stale
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||
let center = Geohash.decodeCenter(geohash)
|
||||
@@ -80,119 +62,42 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
func prefetchIfNeeded() {
|
||||
let now = Date()
|
||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||
// Skip forced fetches if we just refreshed moments ago.
|
||||
return
|
||||
}
|
||||
|
||||
cancelRetry()
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
fetchRemote()
|
||||
}
|
||||
|
||||
private func fetchRemote() {
|
||||
guard !isFetching else { return }
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
|
||||
Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
if !ready {
|
||||
await self.handleFetchFailure(.torNotReady)
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
if !parsed.isEmpty {
|
||||
Task { @MainActor in
|
||||
self.entries = parsed
|
||||
self.persistCache(text)
|
||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||
} catch {
|
||||
await self.handleFetchFailure(.network(error))
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private enum FetchFailure {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(Error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
cancelRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||
switch reason {
|
||||
case .torNotReady:
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let error):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
retryTask = Task { [weak self] in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
await MainActor.run {
|
||||
self?.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = cacheURL() else { return }
|
||||
do {
|
||||
@@ -205,35 +110,30 @@ final class GeoRelayDirectory {
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
// Prefer cached file if present
|
||||
if let cache = cacheURL(),
|
||||
if let cache = self.cacheURL(),
|
||||
let data = try? Data(contentsOf: cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = [
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
@@ -241,6 +141,7 @@ final class GeoRelayDirectory {
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
// Skip header if present
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
@@ -261,76 +162,11 @@ final class GeoRelayDirectory {
|
||||
|
||||
private func cacheURL() -> URL? {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent(cacheFileName)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
private func registerObservers() {
|
||||
let center = NotificationCenter.default
|
||||
|
||||
let torReady = center.addObserver(
|
||||
forName: .TorDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
observers.append(torReady)
|
||||
|
||||
#if os(iOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#elseif os(macOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
} catch { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
struct NostrEmbeddedBitChat {
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
// TLV-encode the private message
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
||||
payload.append(tlv)
|
||||
|
||||
// Determine 8-byte recipient ID to embed
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
payload.append(Data(messageID.utf8))
|
||||
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
|
||||
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerID(publicKey: maybeData)
|
||||
return PeerID(publicKey: maybeData).id
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
|
||||
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -195,7 +195,7 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
//
|
||||
// MimeType.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Extensions for missing UTTypes
|
||||
|
||||
extension UTType {
|
||||
static let webP = UTType(importedAs: "image/webp")
|
||||
static let aac = UTType(importedAs: "audio/aac")
|
||||
static let m4a = UTType(importedAs: "audio/m4a")
|
||||
static let ogg = UTType(importedAs: "audio/ogg")
|
||||
}
|
||||
|
||||
// MARK: - MimeType Enum
|
||||
|
||||
enum MimeType: CaseIterable, Hashable {
|
||||
case jpeg
|
||||
case jpg
|
||||
case png
|
||||
case gif
|
||||
case webp
|
||||
case mp4Audio
|
||||
case m4a
|
||||
case aac
|
||||
case mpeg
|
||||
case mp3
|
||||
case wav
|
||||
case xWav
|
||||
case ogg
|
||||
case pdf
|
||||
case octetStream
|
||||
|
||||
var utType: UTType {
|
||||
switch self {
|
||||
case .jpeg, .jpg: .jpeg
|
||||
case .png: .png
|
||||
case .gif: .gif
|
||||
case .webp: .webP
|
||||
case .aac: .aac
|
||||
case .m4a: .m4a
|
||||
case .mp4Audio: .mpeg4Audio
|
||||
case .mp3, .mpeg: .mp3
|
||||
case .wav, .xWav: .wav
|
||||
case .ogg: .ogg
|
||||
case .pdf: .pdf
|
||||
case .octetStream: .data
|
||||
}
|
||||
}
|
||||
|
||||
var category: Category {
|
||||
switch self {
|
||||
case .jpeg, .jpg, .png, .gif, .webp:
|
||||
return .image
|
||||
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
|
||||
return .audio
|
||||
case .pdf, .octetStream:
|
||||
return .file
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var mimeString: String {
|
||||
switch self {
|
||||
case .jpeg, .jpg: "image/jpeg"
|
||||
case .png: "image/png"
|
||||
case .gif: "image/gif"
|
||||
case .webp: "image/webp"
|
||||
case .mp4Audio: "audio/mp4"
|
||||
case .m4a: "audio/m4a"
|
||||
case .aac: "audio/aac"
|
||||
case .mpeg: "audio/mpeg"
|
||||
case .mp3: "audio/mp3"
|
||||
case .wav: "audio/wav"
|
||||
case .xWav: "audio/x-wav"
|
||||
case .ogg: "audio/ogg"
|
||||
case .pdf: "application/pdf"
|
||||
case .octetStream: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
var defaultExtension: String {
|
||||
switch self {
|
||||
case .jpeg, .jpg: "jpg"
|
||||
case .png: "png"
|
||||
case .webp: "webp"
|
||||
case .gif: "gif"
|
||||
case .mp4Audio, .m4a, .aac: "m4a"
|
||||
case .mpeg, .mp3: "mp3"
|
||||
case .wav, .xWav: "wav"
|
||||
case .ogg: "ogg"
|
||||
case .pdf: "pdf"
|
||||
case .octetStream: "bin"
|
||||
}
|
||||
}
|
||||
|
||||
static var allowed: Set<MimeType> = [
|
||||
.jpeg, .jpg, .png, .gif, .webp,
|
||||
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
|
||||
.wav, .xWav, .ogg,
|
||||
.pdf, .octetStream
|
||||
]
|
||||
|
||||
var isAllowed: Bool {
|
||||
Self.allowed.contains(self)
|
||||
}
|
||||
|
||||
// MARK: - Byte signature validation
|
||||
func matches(data: Data) -> Bool {
|
||||
guard !data.isEmpty else { return false }
|
||||
|
||||
// Generic type → skip validation
|
||||
if self == .octetStream { return true }
|
||||
|
||||
switch self {
|
||||
case .jpeg, .jpg:
|
||||
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
||||
|
||||
case .png:
|
||||
return data.count >= 8 &&
|
||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
|
||||
|
||||
case .gif:
|
||||
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
||||
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
|
||||
|
||||
case .webp:
|
||||
return data.count >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
|
||||
|
||||
case .m4a, .mp4Audio, .aac:
|
||||
// AVAudioRecorder output varies by platform - be lenient
|
||||
// Security: size already capped + sandboxed execution
|
||||
return data.count > 100
|
||||
|
||||
case .mpeg, .mp3:
|
||||
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
||||
return true // ID3 header
|
||||
}
|
||||
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
|
||||
|
||||
case .wav, .xWav:
|
||||
return data.count >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
|
||||
|
||||
case .ogg:
|
||||
return data.count >= 4 &&
|
||||
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
|
||||
|
||||
case .pdf:
|
||||
return data.count >= 4 &&
|
||||
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializers
|
||||
|
||||
init?(_ mimeString: String?) {
|
||||
guard let mimeString else { return nil }
|
||||
|
||||
let normalized = mimeString.lowercased()
|
||||
|
||||
// Direct match with our canonical list
|
||||
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
|
||||
self = match
|
||||
return
|
||||
}
|
||||
|
||||
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
|
||||
if let type = UTType(mimeType: normalized),
|
||||
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
|
||||
self = match
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension MimeType {
|
||||
enum Category: String {
|
||||
case audio, image, file
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
|
||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||
|
||||
// Callbacks
|
||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy support - setting this will add to the handlers array
|
||||
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
|
||||
var onPeerAuthenticated: ((String, String) -> Void)? {
|
||||
get { nil } // Always return nil for backward compatibility
|
||||
set {
|
||||
if let handler = newValue {
|
||||
@@ -546,7 +546,7 @@ final class NoiseEncryptionService {
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
handler(peerID.id, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ final class NostrTransport: Transport {
|
||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||
return
|
||||
}
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -114,7 +114,7 @@ final class NostrTransport: Transport {
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -139,7 +139,7 @@ final class NostrTransport: Transport {
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -161,7 +161,7 @@ extension NostrTransport {
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
@@ -171,7 +171,7 @@ extension NostrTransport {
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
@@ -184,7 +184,7 @@ extension NostrTransport {
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -223,7 +223,7 @@ extension NostrTransport {
|
||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { scheduleNextReadAck(); return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
|
||||
@@ -29,30 +29,28 @@ final class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
func sendLocalNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
identifier: String,
|
||||
userInfo: [String: Any]? = nil,
|
||||
interruptionLevel: UNNotificationInterruptionLevel = .active
|
||||
) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
content.interruptionLevel = interruptionLevel
|
||||
|
||||
if let userInfo = userInfo {
|
||||
content.userInfo = userInfo
|
||||
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
||||
// For now, skip app state check entirely to avoid thread issues
|
||||
// The NotificationDelegate will handle foreground presentation
|
||||
DispatchQueue.main.async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
if let userInfo = userInfo {
|
||||
content.userInfo = userInfo
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
// Notification added
|
||||
}
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func sendMentionNotification(from sender: String, message: String) {
|
||||
@@ -63,11 +61,11 @@ final class NotificationService {
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||
}
|
||||
|
||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
||||
let title = "🔒 DM from \(sender)"
|
||||
let body = message
|
||||
let identifier = "private-\(UUID().uuidString)"
|
||||
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
||||
let userInfo = ["peerID": peerID, "senderName": sender]
|
||||
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
@@ -85,12 +83,25 @@ final class NotificationService {
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||
|
||||
sendLocalNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
identifier: identifier,
|
||||
interruptionLevel: .timeSensitive
|
||||
)
|
||||
|
||||
// For network notifications, we want to show them even in foreground
|
||||
// No app state check - let the notification delegate handle presentation
|
||||
DispatchQueue.main.async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
content.interruptionLevel = .timeSensitive // Make it more prominent
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { _ in
|
||||
// Notification added
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ final class PrivateChatManager: ObservableObject {
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||
readerID: meshService?.myPeerID.id ?? "",
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ struct RelayController {
|
||||
senderIsSelf: Bool,
|
||||
isEncrypted: Bool,
|
||||
isDirectedEncrypted: Bool,
|
||||
isFragment: Bool,
|
||||
isDirectedFragment: Bool,
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
@@ -37,16 +36,6 @@ struct RelayController {
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
|
||||
if isFragment {
|
||||
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
|
||||
guard ttlLimit > 1 else {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
||||
}
|
||||
let newTTL = ttlLimit &- 1
|
||||
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
|
||||
// TTL clamping for broadcast
|
||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||
// - Announces get a bit more headroom
|
||||
|
||||
@@ -45,7 +45,6 @@ protocol Transport: AnyObject {
|
||||
|
||||
// Messaging
|
||||
func sendMessage(_ content: String, mentions: [String])
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
@@ -66,10 +65,6 @@ extension Transport {
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
sendMessage(content, mentions: mentions)
|
||||
}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
|
||||
@@ -8,10 +8,6 @@ enum TransportConfig {
|
||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
|
||||
|
||||
// UI / Storage Caps
|
||||
static let privateChatCap: Int = 1337
|
||||
@@ -70,7 +66,6 @@ enum TransportConfig {
|
||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
||||
|
||||
// BLE maintenance & thresholds
|
||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||
@@ -150,9 +145,6 @@ enum TransportConfig {
|
||||
|
||||
// Geo relay directory
|
||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
|
||||
static let geoRelayRetryInitialSeconds: TimeInterval = 60
|
||||
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
|
||||
|
||||
// BLE operational delays
|
||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||
|
||||
@@ -8,55 +8,6 @@ final class GossipSyncManager {
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||
}
|
||||
|
||||
private struct PacketStore {
|
||||
private(set) var packets: [String: BitchatPacket] = [:]
|
||||
private(set) var order: [String] = []
|
||||
|
||||
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
|
||||
guard capacity > 0 else { return }
|
||||
if packets[idHex] != nil {
|
||||
packets[idHex] = packet
|
||||
return
|
||||
}
|
||||
packets[idHex] = packet
|
||||
order.append(idHex)
|
||||
while order.count > capacity {
|
||||
let victim = order.removeFirst()
|
||||
packets.removeValue(forKey: victim)
|
||||
}
|
||||
}
|
||||
|
||||
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
|
||||
order.compactMap { key in
|
||||
guard let packet = packets[key], isFresh(packet) else { return nil }
|
||||
return packet
|
||||
}
|
||||
}
|
||||
|
||||
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
|
||||
var nextOrder: [String] = []
|
||||
for key in order {
|
||||
guard let packet = packets[key] else { continue }
|
||||
if shouldRemove(packet) {
|
||||
packets.removeValue(forKey: key)
|
||||
} else {
|
||||
nextOrder.append(key)
|
||||
}
|
||||
}
|
||||
order = nextOrder
|
||||
}
|
||||
|
||||
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
|
||||
remove { !isFresh($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SyncSchedule {
|
||||
let types: SyncTypeFlags
|
||||
let interval: TimeInterval
|
||||
var lastSent: Date
|
||||
}
|
||||
|
||||
struct Config {
|
||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||
@@ -65,43 +16,25 @@ final class GossipSyncManager {
|
||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
var fragmentCapacity: Int = 600
|
||||
var fileTransferCapacity: Int = 200
|
||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
}
|
||||
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
weak var delegate: Delegate?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
private var messages = PacketStore()
|
||||
private var fragments = PacketStore()
|
||||
private var fileTransfers = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
||||
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
||||
private var messageOrder: [String] = []
|
||||
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
||||
|
||||
// Timer
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
private var syncSchedules: [SyncSchedule] = []
|
||||
|
||||
init(myPeerID: PeerID, config: Config = Config()) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
syncSchedules = schedules
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -122,18 +55,7 @@ final class GossipSyncManager {
|
||||
|
||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.sendRequestSync(to: peerID, types: .publicMessages)
|
||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID, types: .fragment)
|
||||
}
|
||||
}
|
||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
||||
}
|
||||
}
|
||||
self?.sendRequestSync(to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,45 +87,47 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||
guard let messageType = MessageType(rawValue: packet.type) else { return }
|
||||
let mt = MessageType(rawValue: packet.type)
|
||||
let isBroadcastRecipient: Bool = {
|
||||
guard let r = packet.recipientID else { return true }
|
||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||
}()
|
||||
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
||||
let isAnnounce = (mt == .announce)
|
||||
guard isBroadcastMessage || isAnnounce else { return }
|
||||
|
||||
switch messageType {
|
||||
case .announce:
|
||||
guard isPacketFresh(packet) else { return }
|
||||
// Reject expired packets to prevent ghost peers and old messages
|
||||
guard isPacketFresh(packet) else { return }
|
||||
|
||||
if isAnnounce {
|
||||
guard isAnnouncementFresh(packet) else {
|
||||
let sender = PeerID(hexData: packet.senderID)
|
||||
removeState(for: sender)
|
||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||
removeState(forNormalizedPeerID: sender)
|
||||
return
|
||||
}
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
let sender = PeerID(hexData: packet.senderID)
|
||||
}
|
||||
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
|
||||
if isBroadcastMessage {
|
||||
if messages[idHex] == nil {
|
||||
messages[idHex] = packet
|
||||
messageOrder.append(idHex)
|
||||
// Enforce capacity
|
||||
let cap = max(1, config.seenCapacity)
|
||||
while messageOrder.count > cap {
|
||||
let victim = messageOrder.removeFirst()
|
||||
messages.removeValue(forKey: victim)
|
||||
}
|
||||
}
|
||||
} else if isAnnounce {
|
||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||
case .message:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||
case .fragment:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
|
||||
case .fileTransfer:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||
let payload = buildGcsPayload(for: types)
|
||||
private func sendRequestSync() {
|
||||
let payload = buildGcsPayload()
|
||||
let pkt = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||
@@ -217,8 +141,8 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||
let payload = buildGcsPayload(for: types)
|
||||
private func sendRequestSync(to peerID: PeerID) {
|
||||
let payload = buildGcsPayload()
|
||||
var recipient = Data()
|
||||
var temp = peerID.id
|
||||
while temp.count >= 2 && recipient.count < 8 {
|
||||
@@ -246,7 +170,6 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
let requestedTypes = (request.types ?? .publicMessages)
|
||||
// Decode GCS into sorted set and prepare membership checker
|
||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||
func mightContain(_ id: Data) -> Bool {
|
||||
@@ -254,100 +177,60 @@ final class GossipSyncManager {
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.announce) {
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.message) {
|
||||
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in toSendMsgs {
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fragment) {
|
||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in frags {
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fileTransfer) {
|
||||
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in files {
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
// 2) Broadcast messages: send all missing (and not expired)
|
||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||
for pkt in toSendMsgs {
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
||||
private func buildGcsPayload() -> Data {
|
||||
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||
var candidates: [BitchatPacket] = []
|
||||
if types.contains(.announce) {
|
||||
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
if isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
}
|
||||
}
|
||||
if types.contains(.message) {
|
||||
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
|
||||
for id in messageOrder {
|
||||
if let p = messages[id], isPacketFresh(p) {
|
||||
candidates.append(p)
|
||||
}
|
||||
}
|
||||
if types.contains(.fragment) {
|
||||
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.fileTransfer) {
|
||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
// Sort by timestamp desc
|
||||
candidates.sort { $0.timestamp > $1.timestamp }
|
||||
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||
let cap: Int
|
||||
if types == .fragment {
|
||||
cap = max(1, config.fragmentCapacity)
|
||||
} else if types == .fileTransfer {
|
||||
cap = max(1, config.fileTransferCapacity)
|
||||
} else {
|
||||
cap = max(1, config.seenCapacity)
|
||||
}
|
||||
let cap = max(1, config.seenCapacity)
|
||||
let takeN = min(candidates.count, min(nMax, cap))
|
||||
if takeN <= 0 {
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
||||
return req.encode()
|
||||
}
|
||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
@@ -358,21 +241,20 @@ final class GossipSyncManager {
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
|
||||
messages.removeExpired(isFresh: isPacketFresh)
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
// Remove expired messages
|
||||
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||
isPacketFresh(pkt) ? nil : id
|
||||
}
|
||||
for id in expiredMessageIds {
|
||||
messages.removeValue(forKey: id)
|
||||
messageOrder.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
sendRequestSync(for: syncSchedules[index].types)
|
||||
}
|
||||
}
|
||||
sendRequestSync()
|
||||
}
|
||||
|
||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||
@@ -388,27 +270,40 @@ final class GossipSyncManager {
|
||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||
guard nowMs >= timeoutMs else { return }
|
||||
let cutoff = nowMs - timeoutMs
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
||||
pair.packet.timestamp < cutoff ? peerID : nil
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
||||
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
||||
}
|
||||
guard !stalePeerIDs.isEmpty else { return }
|
||||
for peerKey in stalePeerIDs {
|
||||
removeState(for: peerKey)
|
||||
removeState(forNormalizedPeerID: peerKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit removal hook for LEAVE/stale peer
|
||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?.removeState(for: peerID)
|
||||
self?._removeAnnouncementForPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeState(for peerID: PeerID) {
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.id.lowercased()
|
||||
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||
}
|
||||
|
||||
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||
// Remove messages from this peer
|
||||
// Collect IDs to remove first to avoid concurrent modification
|
||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
|
||||
}
|
||||
|
||||
// Remove messages and update messageOrder
|
||||
for id in messageIdsToRemove {
|
||||
messages.removeValue(forKey: id)
|
||||
messageOrder.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,13 +317,13 @@ extension GossipSyncManager {
|
||||
|
||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
latestAnnouncementByPeer[peerID] != nil
|
||||
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||
}
|
||||
}
|
||||
|
||||
func _messageCount(for peerID: PeerID) -> Int {
|
||||
queue.sync {
|
||||
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
||||
/// Matches the Android mapping (bit index -> message type).
|
||||
struct SyncTypeFlags: OptionSet {
|
||||
let rawValue: UInt64
|
||||
|
||||
init(rawValue: UInt64) {
|
||||
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
|
||||
}
|
||||
|
||||
private static func bitIndex(for type: MessageType) -> Int? {
|
||||
switch type {
|
||||
case .announce: return 0
|
||||
case .message: return 1
|
||||
case .leave: return 2
|
||||
case .noiseHandshake: return 3
|
||||
case .noiseEncrypted: return 4
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
}
|
||||
}
|
||||
|
||||
private static func type(forBit index: Int) -> MessageType? {
|
||||
switch index {
|
||||
case 0: return .announce
|
||||
case 1: return .message
|
||||
case 2: return .leave
|
||||
case 3: return .noiseHandshake
|
||||
case 4: return .noiseEncrypted
|
||||
case 5: return .fragment
|
||||
case 6: return .requestSync
|
||||
case 7: return .fileTransfer
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static let announce = SyncTypeFlags(messageTypes: [.announce])
|
||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||
|
||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||
|
||||
init(messageTypes: [MessageType]) {
|
||||
var raw: UInt64 = 0
|
||||
for type in messageTypes {
|
||||
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
|
||||
raw |= (1 << UInt64(bit))
|
||||
}
|
||||
self.init(rawValue: raw)
|
||||
}
|
||||
|
||||
func contains(_ type: MessageType) -> Bool {
|
||||
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
|
||||
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
|
||||
}
|
||||
|
||||
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
||||
SyncTypeFlags(rawValue: rawValue | other.rawValue)
|
||||
}
|
||||
|
||||
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
||||
SyncTypeFlags(rawValue: rawValue & other.rawValue)
|
||||
}
|
||||
|
||||
func toMessageTypes() -> [MessageType] {
|
||||
guard rawValue != 0 else { return [] }
|
||||
var types: [MessageType] = []
|
||||
for bit in 0..<64 {
|
||||
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
|
||||
if let type = SyncTypeFlags.type(forBit: bit) {
|
||||
types.append(type)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func toData() -> Data? {
|
||||
guard rawValue != 0 else { return nil }
|
||||
var value = rawValue
|
||||
var bytes: [UInt8] = []
|
||||
while value > 0 && bytes.count < 8 {
|
||||
bytes.append(UInt8(value & 0xFF))
|
||||
value >>= 8
|
||||
}
|
||||
while let last = bytes.last, last == 0 {
|
||||
bytes.removeLast()
|
||||
}
|
||||
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
|
||||
return Data(bytes)
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> SyncTypeFlags? {
|
||||
guard (1...8).contains(data.count) else { return nil }
|
||||
var raw: UInt64 = 0
|
||||
for (index, byte) in data.enumerated() {
|
||||
raw |= UInt64(byte) << UInt64(index * 8)
|
||||
}
|
||||
return SyncTypeFlags(rawValue: raw)
|
||||
}
|
||||
}
|
||||
@@ -61,13 +61,15 @@ struct CompressionUtil {
|
||||
// 1. Data is too small
|
||||
// 2. Data appears to be already compressed (high entropy)
|
||||
guard data.count >= compressionThreshold else { return false }
|
||||
|
||||
// Quick uniqueness check — a high diversity of bytes usually means the
|
||||
// payload is already compressed. We only need to know how many unique
|
||||
// values exist rather than keeping full frequency counts.
|
||||
let uniqueByteCount = Set(data).count
|
||||
let sampleSize = min(data.count, 256)
|
||||
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
|
||||
|
||||
// Simple entropy check - count unique bytes
|
||||
var byteFrequency = [UInt8: Int]()
|
||||
for byte in data {
|
||||
byteFrequency[byte, default: 0] += 1
|
||||
}
|
||||
|
||||
// If we have very high byte diversity, data is likely already compressed
|
||||
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
|
||||
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ enum FileTransferLimits {
|
||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Voice notes stay small for low-latency relays.
|
||||
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||
static let maxFramedFileBytes: Int = {
|
||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Comprehensive input validation for BitChat protocol
|
||||
/// Prevents injection attacks, buffer overflows, and malformed data
|
||||
@@ -17,28 +16,29 @@ struct InputValidator {
|
||||
// MARK: - String Content Validation
|
||||
|
||||
/// Validates and sanitizes user-provided strings used in UI
|
||||
///
|
||||
/// Rejects strings containing control characters to prevent potential security issues
|
||||
/// and UI rendering problems. This strict approach ensures data integrity at input time.
|
||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||
// Check empty
|
||||
guard !string.isEmpty else { return nil }
|
||||
|
||||
// Trim whitespace
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
// Check length
|
||||
guard trimmed.count <= maxLength else { return nil }
|
||||
|
||||
// Reject control characters outright instead of rewriting the string.
|
||||
// This prevents injection attacks and ensures consistent UI rendering.
|
||||
// Remove control characters
|
||||
let controlChars = CharacterSet.controlCharacters
|
||||
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
|
||||
// Log rejection for monitoring, without exposing actual content for privacy
|
||||
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
|
||||
SecureLogger.debug(
|
||||
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
|
||||
category: .security
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
return trimmed
|
||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||
|
||||
// Ensure valid UTF-8 (should already be, but double-check)
|
||||
guard cleaned.data(using: .utf8) != nil else { return nil }
|
||||
|
||||
// Prevent zero-width characters and other invisible unicode
|
||||
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
|
||||
let visible = cleaned.components(separatedBy: invisibleChars).joined()
|
||||
|
||||
return visible.isEmpty ? nil : visible
|
||||
}
|
||||
|
||||
/// Validates nickname
|
||||
|
||||
@@ -145,19 +145,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool)
|
||||
|
||||
@MainActor
|
||||
private var canSendMediaInCurrentContext: Bool {
|
||||
if let peer = selectedPrivateChatPeer {
|
||||
return !(peer.isGeoDM || peer.isGeoChat)
|
||||
}
|
||||
switch activeChannel {
|
||||
case .mesh: return true
|
||||
case .location: return false
|
||||
}
|
||||
}
|
||||
|
||||
private var rateBucketsBySender: [String: TokenBucket] = [:]
|
||||
private var rateBucketsByContent: [String: TokenBucket] = [:]
|
||||
private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity
|
||||
@@ -171,7 +158,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if spid.isGeoChat || spid.isGeoDM {
|
||||
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
|
||||
return "nostr:" + full
|
||||
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() {
|
||||
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
||||
return "noise:" + full
|
||||
} else {
|
||||
return "mesh:" + spid.id.lowercased()
|
||||
@@ -248,10 +235,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
@Published var currentColorScheme: ColorScheme = .light
|
||||
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
||||
@Published var isConnected = false
|
||||
private var hasNotifiedNetworkAvailable = false
|
||||
private var recentlySeenPeers: Set<PeerID> = []
|
||||
private var lastNetworkNotificationTime = Date.distantPast
|
||||
private var networkResetTimer: Timer? = nil
|
||||
private var networkEmptyTimer: Timer? = nil
|
||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||
@Published var nickname: String = "" {
|
||||
didSet {
|
||||
@@ -261,7 +248,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
nickname = trimmed
|
||||
}
|
||||
// Update mesh service nickname if it's initialized
|
||||
if !meshService.myPeerID.isEmpty {
|
||||
if meshService.myPeerID != "" {
|
||||
meshService.setNickname(nickname)
|
||||
}
|
||||
}
|
||||
@@ -328,16 +315,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]
|
||||
private var selectedPrivateChatFingerprint: String? = nil
|
||||
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
||||
private var shortIDToNoiseKey: [PeerID: PeerID] = [:]
|
||||
private var shortIDToNoiseKey: [PeerID: String] = [:]
|
||||
|
||||
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
|
||||
@MainActor
|
||||
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? {
|
||||
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> String? {
|
||||
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
|
||||
// Fallback: derive from active Noise session if available
|
||||
if shortPeerID.id.count == 16,
|
||||
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
||||
let stable = PeerID(hexData: key)
|
||||
let stable = key.hexEncodedString()
|
||||
shortIDToNoiseKey[shortPeerID] = stable
|
||||
return stable
|
||||
}
|
||||
@@ -346,17 +333,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)
|
||||
@MainActor
|
||||
func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID {
|
||||
guard fullNoiseKeyHex.id.count == 64 else { return fullNoiseKeyHex }
|
||||
func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> PeerID? {
|
||||
// Check known peers for a noise key match
|
||||
if let match = allPeers.first(where: { PeerID(hexData: $0.noisePublicKey) == fullNoiseKeyHex }) {
|
||||
if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) {
|
||||
return match.peerID
|
||||
}
|
||||
// Also search cache mapping
|
||||
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
|
||||
return pair.key
|
||||
}
|
||||
return fullNoiseKeyHex
|
||||
return nil
|
||||
}
|
||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||
|
||||
@@ -1155,7 +1141,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderName,
|
||||
message: pm.content,
|
||||
peerID: convKey
|
||||
peerID: convKey.id
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1457,48 +1443,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Parse mentions from the content (use original content for user intent)
|
||||
let mentions = parseMentions(from: content)
|
||||
|
||||
var geoContext: GeoOutgoingContext? = nil
|
||||
|
||||
// Add message to local display
|
||||
var displaySender = nickname
|
||||
var localSenderPeerID = meshService.myPeerID
|
||||
var messageID: String? = nil
|
||||
var messageTimestamp = Date()
|
||||
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
break
|
||||
case .location(let ch):
|
||||
do {
|
||||
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||
displaySender = nickname + "#" + suffix
|
||||
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||
let teleported = LocationChannelManager.shared.teleported
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: trimmed,
|
||||
geohash: ch.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: nickname,
|
||||
teleported: teleported
|
||||
)
|
||||
messageID = event.id
|
||||
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
geoContext = (channel: ch, event: event, identity: identity, teleported: teleported)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||
addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
if case .location(let ch) = activeChannel,
|
||||
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
|
||||
displaySender = nickname + "#" + suffix
|
||||
localSenderPeerID = PeerID(nostr: myGeoIdentity.publicKeyHex)
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: displaySender,
|
||||
content: trimmed,
|
||||
timestamp: messageTimestamp,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
senderPeerID: localSenderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
@@ -1529,75 +1487,69 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// UI updates automatically via @Published var messages
|
||||
|
||||
updateChannelActivityTimeThenSend(content: content,
|
||||
trimmed: trimmed,
|
||||
mentions: mentions,
|
||||
geoContext: geoContext,
|
||||
messageID: message.id,
|
||||
timestamp: message.timestamp)
|
||||
updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions)
|
||||
}
|
||||
|
||||
private func updateChannelActivityTimeThenSend(content: String,
|
||||
trimmed: String,
|
||||
mentions: [String],
|
||||
geoContext: GeoOutgoingContext?,
|
||||
messageID: String,
|
||||
timestamp: Date) {
|
||||
private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String]) {
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
lastPublicActivityAt["mesh"] = Date()
|
||||
// Send via mesh with mentions
|
||||
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||||
meshService.sendMessage(content, mentions: mentions)
|
||||
case .location(let ch):
|
||||
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
||||
guard let context = geoContext, context.channel.geohash == ch.geohash else {
|
||||
SecureLogger.error("Geo: missing send context for \(ch.geohash)", category: .session)
|
||||
addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
// Send to geohash channel via Nostr ephemeral
|
||||
Task { @MainActor in
|
||||
self.sendGeohash(context: context)
|
||||
sendGeohash(ch: ch, content: trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func sendGeohash(context: GeoOutgoingContext) {
|
||||
let ch = context.channel
|
||||
let event = context.event
|
||||
let identity = context.identity
|
||||
private func sendGeohash(ch: GeohashChannel, content: String) {
|
||||
do {
|
||||
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: ch.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: nickname,
|
||||
teleported: LocationChannelManager.shared.teleported
|
||||
)
|
||||
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
|
||||
if targetRelays.isEmpty {
|
||||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
if targetRelays.isEmpty {
|
||||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
// Track ourselves as active participant
|
||||
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = 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
|
||||
// Only when not in our regional set (and regional list is known)
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
|
||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send geohash message: \(error)", category: .session)
|
||||
addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
}
|
||||
|
||||
// Track ourselves as active participant
|
||||
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
|
||||
|
||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||
// Only when not in our regional set (and regional list is known)
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
|
||||
if context.teleported && hasRegional && !inRegional {
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
}
|
||||
|
||||
recordProcessedEvent(event.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -1886,7 +1838,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderName,
|
||||
message: pm.content,
|
||||
peerID: convKey
|
||||
peerID: convKey.id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1966,7 +1918,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||
guard let peerID else { return false }
|
||||
if peerID == meshService.myPeerID { return true }
|
||||
guard peerID.isGeoDM || peerID.isGeoChat else { return false }
|
||||
let lowerPeer = peerID.id.lowercased()
|
||||
guard lowerPeer.hasPrefix("nostr") else { return false }
|
||||
|
||||
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
|
||||
let gh = currentGeohash,
|
||||
@@ -1976,7 +1929,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
if let gh = currentGeohash,
|
||||
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||||
if peerID == PeerID(nostr: myIdentity.publicKeyHex) { return true }
|
||||
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 }
|
||||
@@ -2451,15 +2407,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
func sendVoiceNote(at url: URL) {
|
||||
guard canSendMediaInCurrentContext else {
|
||||
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
addSystemMessage("Voice notes are only available in mesh chats.")
|
||||
return
|
||||
}
|
||||
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer?.id)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
|
||||
@@ -2506,13 +2455,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
||||
guard canSendMediaInCurrentContext else {
|
||||
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
|
||||
cleanup?()
|
||||
addSystemMessage("Images are only available in mesh chats.")
|
||||
return
|
||||
}
|
||||
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
@@ -2538,7 +2480,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
)
|
||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer?.id)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
@@ -2580,7 +2522,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
||||
private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage {
|
||||
let timestamp = Date()
|
||||
let message: BitchatMessage
|
||||
|
||||
@@ -2597,7 +2539,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
var chats = privateChats
|
||||
chats[peerID, default: []].append(message)
|
||||
chats[PeerID(str: peerID), default: []].append(message)
|
||||
privateChats = chats
|
||||
trimMessagesIfNeeded()
|
||||
} else {
|
||||
@@ -2610,7 +2552,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderPeerID,
|
||||
senderPeerID: PeerID(str: senderPeerID),
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
messages.append(message)
|
||||
@@ -2635,28 +2577,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return message
|
||||
}
|
||||
|
||||
private func currentPublicSender() -> (name: String, peerID: PeerID) {
|
||||
private func currentPublicSender() -> (name: String, peerID: String) {
|
||||
var displaySender = nickname
|
||||
var senderPeerID = meshService.myPeerID
|
||||
if case .location(let ch) = activeChannel,
|
||||
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||
displaySender = nickname + "#" + suffix
|
||||
senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
|
||||
senderPeerID = PeerID(str: "nostr:\(shortKey)")
|
||||
}
|
||||
return (displaySender, senderPeerID)
|
||||
return (displaySender, senderPeerID.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func nicknameForPeer(_ peerID: PeerID) -> String {
|
||||
if let name = meshService.peerNickname(peerID: peerID) {
|
||||
private func nicknameForPeer(_ peerID: String) -> String {
|
||||
if let name = meshService.peerNickname(peerID: PeerID(str: peerID)) {
|
||||
return name
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: PeerID(str: peerID)),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
@@ -3345,10 +3288,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// In public chat - send to active public channel
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshService.sendMessage(screenshotMessage,
|
||||
mentions: [],
|
||||
messageID: UUID().uuidString,
|
||||
timestamp: Date())
|
||||
meshService.sendMessage(screenshotMessage, mentions: [])
|
||||
case .location(let ch):
|
||||
Task { @MainActor in
|
||||
do {
|
||||
@@ -3502,7 +3442,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
// Use stable Noise key hex if available; else fall back to peerID
|
||||
let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
|
||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname)
|
||||
messageRouter.sendReadReceipt(receipt, to: recipPeer)
|
||||
sentReadReceipts.insert(message.id)
|
||||
}
|
||||
@@ -4116,7 +4056,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if let spid = message.senderPeerID {
|
||||
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
|
||||
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||
}
|
||||
}
|
||||
return spid == meshService.myPeerID
|
||||
@@ -4269,7 +4209,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let noiseService = meshService.getNoiseService()
|
||||
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
|
||||
// Check if fingerprint is verified using our persisted data
|
||||
if let fingerprint = getFingerprint(for: peerID),
|
||||
verifiedFingerprints.contains(fingerprint) {
|
||||
peerEncryptionStatus[peerID] = .noiseVerified
|
||||
} else {
|
||||
peerEncryptionStatus[peerID] = .noiseSecured
|
||||
}
|
||||
} else if noiseService.hasSession(with: peerID) {
|
||||
// Session exists but not established - handshaking
|
||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||||
@@ -4304,12 +4250,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Determine status based on session state
|
||||
switch sessionState {
|
||||
case .established:
|
||||
status = encryptionStatus(for: peerID)
|
||||
// We have encryption, now check if it's verified
|
||||
if let fingerprint = getFingerprint(for: peerID) {
|
||||
if verifiedFingerprints.contains(fingerprint) {
|
||||
status = .noiseVerified
|
||||
} else {
|
||||
status = .noiseSecured
|
||||
}
|
||||
} else {
|
||||
// We have a session but no fingerprint yet - still secured
|
||||
status = .noiseSecured
|
||||
}
|
||||
case .handshaking, .handshakeQueued:
|
||||
// If we've ever established a session, show secured instead of handshaking
|
||||
if hasEverEstablishedSession {
|
||||
// Check if it was verified before
|
||||
status = encryptionStatus(for: peerID)
|
||||
if let fingerprint = getFingerprint(for: peerID),
|
||||
verifiedFingerprints.contains(fingerprint) {
|
||||
status = .noiseVerified
|
||||
} else {
|
||||
status = .noiseSecured
|
||||
}
|
||||
} else {
|
||||
// First time establishing - show handshaking
|
||||
status = .noiseHandshaking
|
||||
@@ -4318,7 +4279,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// If we've ever established a session, show secured instead of no handshake
|
||||
if hasEverEstablishedSession {
|
||||
// Check if it was verified before
|
||||
status = encryptionStatus(for: peerID)
|
||||
if let fingerprint = getFingerprint(for: peerID),
|
||||
verifiedFingerprints.contains(fingerprint) {
|
||||
status = .noiseVerified
|
||||
} else {
|
||||
status = .noiseSecured
|
||||
}
|
||||
} else {
|
||||
// Never established - show no handshake
|
||||
status = .noHandshake
|
||||
@@ -4327,7 +4293,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// If we've ever established a session, show secured instead of failed
|
||||
if hasEverEstablishedSession {
|
||||
// Check if it was verified before
|
||||
status = encryptionStatus(for: peerID)
|
||||
if let fingerprint = getFingerprint(for: peerID),
|
||||
verifiedFingerprints.contains(fingerprint) {
|
||||
status = .noiseVerified
|
||||
} else {
|
||||
status = .noiseSecured
|
||||
}
|
||||
} else {
|
||||
// Never established - show failed
|
||||
status = .none
|
||||
@@ -4401,7 +4372,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
private func meshSeed(for peerID: PeerID) -> String {
|
||||
if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() {
|
||||
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
||||
return "noise:" + full
|
||||
}
|
||||
return peerID.id.lowercased()
|
||||
@@ -4718,7 +4689,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let noiseService = meshService.getNoiseService()
|
||||
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
|
||||
if let fingerprint = getFingerprint(for: peerID) {
|
||||
if verifiedFingerprints.contains(fingerprint) {
|
||||
peerEncryptionStatus[peerID] = .noiseVerified
|
||||
} else {
|
||||
peerEncryptionStatus[peerID] = .noiseSecured
|
||||
}
|
||||
} else {
|
||||
// Session established but no fingerprint yet
|
||||
peerEncryptionStatus[peerID] = .noiseSecured
|
||||
}
|
||||
} else if noiseService.hasSession(with: peerID) {
|
||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||||
} else {
|
||||
@@ -4748,16 +4728,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return unifiedPeerService.getFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
/// Check if fingerprint is verified using our persisted data
|
||||
@MainActor
|
||||
private func encryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
||||
if let fp = getFingerprint(for: peerID), verifiedFingerprints.contains(fp) {
|
||||
return .noiseVerified
|
||||
} else {
|
||||
return .noiseSecured
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to resolve nickname for a peer ID through various sources
|
||||
@MainActor
|
||||
private func resolveNickname(for peerID: PeerID) -> String {
|
||||
@@ -4859,6 +4829,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
let peerID = PeerID(str: peerID)
|
||||
|
||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||
|
||||
@@ -4877,9 +4848,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Cache shortID -> full Noise key mapping as soon as session authenticates
|
||||
if self.shortIDToNoiseKey[peerID] == nil,
|
||||
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||||
let stable = PeerID(hexData: keyData)
|
||||
let stable = keyData.hexEncodedString()
|
||||
self.shortIDToNoiseKey[peerID] = stable
|
||||
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))…", category: .session)
|
||||
}
|
||||
|
||||
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
||||
@@ -5063,12 +5034,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
Task { @MainActor in
|
||||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let publicMentions = parseMentions(from: normalized)
|
||||
let msg = BitchatMessage(
|
||||
id: messageID,
|
||||
id: UUID().uuidString,
|
||||
sender: nickname,
|
||||
content: normalized,
|
||||
timestamp: timestamp,
|
||||
@@ -5144,7 +5115,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Cache mapping to full Noise key for session continuity on disconnect
|
||||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
|
||||
shortIDToNoiseKey[peerID] = noiseKeyHex
|
||||
}
|
||||
|
||||
@@ -5160,14 +5131,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
identityManager.removeEphemeralSession(peerID: peerID)
|
||||
|
||||
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
|
||||
var derivedStableKeyHex = shortIDToNoiseKey[peerID]
|
||||
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
|
||||
if derivedStableKeyHex == nil,
|
||||
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||||
derivedStableKeyHex = PeerID(hexData: key)
|
||||
derivedStableKeyHex = key.hexEncodedString()
|
||||
shortIDToNoiseKey[peerID] = derivedStableKeyHex
|
||||
}
|
||||
|
||||
if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex {
|
||||
if let current = selectedPrivateChatPeer, current == peerID,
|
||||
let stableKeyHex = PeerID(str: derivedStableKeyHex) {
|
||||
// Migrate messages view context to stable key so header shows favorite + Nostr globe
|
||||
if let messages = privateChats[peerID] {
|
||||
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
|
||||
@@ -5229,29 +5201,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.cleanupStaleUnreadPeerIDs()
|
||||
|
||||
// Smart notification logic for "bitchatters nearby"
|
||||
let meshPeers = peers.filter { peerID in
|
||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||
}
|
||||
let meshPeerSet = Set(meshPeers)
|
||||
|
||||
if meshPeerSet.isEmpty {
|
||||
self.scheduleNetworkEmptyTimer()
|
||||
} else {
|
||||
self.invalidateNetworkEmptyTimer()
|
||||
// Trim out peers we no longer observe before comparing for new arrivals
|
||||
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||||
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
|
||||
|
||||
if !newPeers.isEmpty {
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
self.recentlySeenPeers.formUnion(newPeers)
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
)
|
||||
self.scheduleNetworkResetTimer()
|
||||
if !peers.isEmpty {
|
||||
// Cancel any pending reset if peers are back
|
||||
self.networkResetTimer?.invalidate()
|
||||
self.networkResetTimer = nil
|
||||
// Count mesh peers that are connected OR recently reachable via mesh relays
|
||||
let meshPeers = peers.filter { peerID in
|
||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||
}
|
||||
|
||||
// Rising-edge only: previously zero peers, now > 0 peers
|
||||
let currentPeerSet = Set(meshPeers)
|
||||
let hadNone = self.recentlySeenPeers.isEmpty
|
||||
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
|
||||
self.hasNotifiedNetworkAvailable = true
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
self.recentlySeenPeers = currentPeerSet
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
SecureLogger.info("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", category: .session)
|
||||
}
|
||||
} else {
|
||||
// No peers — immediately reset to allow next rising-edge to notify
|
||||
self.hasNotifiedNetworkAvailable = false
|
||||
self.recentlySeenPeers.removeAll()
|
||||
if self.networkResetTimer != nil {
|
||||
self.networkResetTimer?.invalidate()
|
||||
self.networkResetTimer = nil
|
||||
}
|
||||
SecureLogger.debug("⏳ Mesh empty — reset network notification state", category: .session)
|
||||
}
|
||||
|
||||
// Register ephemeral sessions for all connected peers
|
||||
@@ -5321,71 +5298,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Keep only receipts from messages we still have
|
||||
cleanupOldReadReceipts()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleNetworkResetTimer() {
|
||||
networkResetTimer?.invalidate()
|
||||
networkResetTimer = Timer.scheduledTimer(
|
||||
timeInterval: networkResetGraceSeconds,
|
||||
target: self,
|
||||
selector: #selector(onNetworkResetTimerFired(_:)),
|
||||
userInfo: nil,
|
||||
repeats: false
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc private func onNetworkResetTimerFired(_ timer: Timer) {
|
||||
let activeMeshPeers = meshService
|
||||
.currentPeerSnapshots()
|
||||
.filter { snapshot in
|
||||
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
|
||||
}
|
||||
if activeMeshPeers.isEmpty {
|
||||
recentlySeenPeers.removeAll()
|
||||
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
|
||||
} else {
|
||||
SecureLogger.debug("⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", category: .session)
|
||||
}
|
||||
networkResetTimer = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleNetworkEmptyTimer() {
|
||||
guard networkEmptyTimer == nil else { return }
|
||||
networkEmptyTimer = Timer.scheduledTimer(
|
||||
timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds,
|
||||
target: self,
|
||||
selector: #selector(onNetworkEmptyTimerFired(_:)),
|
||||
userInfo: nil,
|
||||
repeats: false
|
||||
)
|
||||
SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func invalidateNetworkEmptyTimer() {
|
||||
if networkEmptyTimer != nil {
|
||||
networkEmptyTimer?.invalidate()
|
||||
networkEmptyTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc private func onNetworkEmptyTimerFired(_ timer: Timer) {
|
||||
let activeMeshPeers = meshService
|
||||
.currentPeerSnapshots()
|
||||
.filter { snapshot in
|
||||
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
|
||||
}
|
||||
if activeMeshPeers.isEmpty {
|
||||
recentlySeenPeers.removeAll()
|
||||
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
|
||||
} else {
|
||||
SecureLogger.debug("⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", category: .session)
|
||||
}
|
||||
networkEmptyTimer = nil
|
||||
}
|
||||
|
||||
private func cleanupOldReadReceipts() {
|
||||
// Skip cleanup during startup phase or if privateChats is empty
|
||||
@@ -5542,9 +5454,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
)
|
||||
// Append to current visible messages
|
||||
messages.append(systemMessage)
|
||||
// Track the content key so relayed copies of the same system-style message are ignored
|
||||
let contentKey = normalizedContentKey(systemMessage.content)
|
||||
recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
||||
// Persist into the backing store for the active channel to survive rebinds
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
@@ -5595,10 +5504,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return
|
||||
}
|
||||
// Default: send over mesh
|
||||
meshService.sendMessage(content,
|
||||
mentions: [],
|
||||
messageID: UUID().uuidString,
|
||||
timestamp: Date())
|
||||
meshService.sendMessage(content, mentions: [])
|
||||
}
|
||||
|
||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||
@@ -5672,7 +5578,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
// Validate recipient
|
||||
if PeerID(hexData: packet.recipientID) != meshService.myPeerID {
|
||||
if let rid = packet.recipientID, rid.hexEncodedString() != meshService.myPeerID {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5882,7 +5788,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
if let key {
|
||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname)
|
||||
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
||||
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
||||
sentReadReceipts.insert(message.id)
|
||||
@@ -5917,7 +5823,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderNickname,
|
||||
message: messageContent,
|
||||
peerID: targetPeerID
|
||||
peerID: targetPeerID.id
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6310,7 +6216,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
peerID: peerID
|
||||
peerID: peerID.id
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -6325,7 +6231,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService.myPeerID,
|
||||
readerID: meshService.myPeerID.id,
|
||||
readerNickname: nickname
|
||||
)
|
||||
|
||||
@@ -6364,9 +6270,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
||||
|
||||
// Apply per-sender and per-content rate limits (drop if exceeded)
|
||||
// Treat action-style system messages (which carry a senderPeerID) the same as regular user messages
|
||||
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
|
||||
if shouldRateLimit {
|
||||
if finalMessage.sender != "system" {
|
||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||
let contentKey = normalizedContentKey(finalMessage.content)
|
||||
let now = Date()
|
||||
@@ -6384,10 +6288,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Persist mesh messages to mesh timeline always
|
||||
if !isGeo && finalMessage.sender != "system" {
|
||||
if !meshTimeline.contains(where: { $0.id == finalMessage.id }) {
|
||||
meshTimeline.append(finalMessage)
|
||||
trimMeshTimelineIfNeeded()
|
||||
}
|
||||
meshTimeline.append(finalMessage)
|
||||
trimMeshTimelineIfNeeded()
|
||||
}
|
||||
|
||||
// Persist geochat messages to per-geohash timeline
|
||||
|
||||
+72
-136
@@ -47,7 +47,7 @@ struct ContentView: View {
|
||||
@State private var commandSuggestions: [String] = []
|
||||
@State private var showMessageActions = false
|
||||
@State private var selectedMessageSender: String?
|
||||
@State private var selectedMessageSenderID: PeerID?
|
||||
@State private var selectedMessageSenderID: String?
|
||||
@FocusState private var isNicknameFieldFocused: Bool
|
||||
@State private var isAtBottomPublic: Bool = true
|
||||
@State private var isAtBottomPrivate: Bool = true
|
||||
@@ -80,7 +80,7 @@ struct ContentView: View {
|
||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||
// Window sizes for rendering (infinite scroll up)
|
||||
@State private var windowCountPublic: Int = 300
|
||||
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
||||
@State private var windowCountPrivate: [String: Int] = [:]
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
@@ -124,7 +124,7 @@ struct ContentView: View {
|
||||
|
||||
|
||||
private struct PrivateHeaderContext {
|
||||
let headerPeerID: PeerID
|
||||
let headerPeerID: String
|
||||
let peer: BitchatPeer?
|
||||
let displayName: String
|
||||
let isNostrAvailable: Bool
|
||||
@@ -188,6 +188,10 @@ struct ContentView: View {
|
||||
)
|
||||
) {
|
||||
peopleSheetView
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $showAppInfo) {
|
||||
AppInfoView()
|
||||
@@ -199,19 +203,11 @@ struct ContentView: View {
|
||||
set: { _ in viewModel.showingFingerprintFor = nil }
|
||||
)) {
|
||||
if let peerID = viewModel.showingFingerprintFor {
|
||||
FingerprintView(viewModel: viewModel, peerID: peerID)
|
||||
FingerprintView(viewModel: viewModel, peerID: peerID.id)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// Only present image picker from main view when NOT in a sheet
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
showImagePicker = false
|
||||
}
|
||||
}
|
||||
)) {
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||
showImagePicker = false
|
||||
if let image = image {
|
||||
@@ -227,19 +223,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Only present Mac image picker from main view when NOT in a sheet
|
||||
.sheet(isPresented: Binding(
|
||||
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
showMacImagePicker = false
|
||||
}
|
||||
}
|
||||
)) {
|
||||
.sheet(isPresented: $showMacImagePicker) {
|
||||
MacImagePickerView { url in
|
||||
showMacImagePicker = false
|
||||
if let url = url {
|
||||
@@ -285,12 +275,12 @@ struct ContentView: View {
|
||||
|
||||
Button("content.actions.direct_message") {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
if peerID.isGeoChat {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
if peerID.hasPrefix("nostr:") {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) {
|
||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||
}
|
||||
} else {
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
||||
}
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
@@ -312,8 +302,8 @@ struct ContentView: View {
|
||||
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
// Prefer direct geohash block when we have a Nostr sender ID
|
||||
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
||||
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)),
|
||||
let sender = selectedMessageSender {
|
||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||
} else if let sender = selectedMessageSender {
|
||||
@@ -344,9 +334,9 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - Message List View
|
||||
|
||||
private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {
|
||||
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
|
||||
let messages: [BitchatMessage] = {
|
||||
if let peerID = privatePeer {
|
||||
if let peerID = PeerID(str: privatePeer) {
|
||||
return viewModel.getPrivateChatMessages(for: peerID)
|
||||
}
|
||||
return viewModel.messages
|
||||
@@ -486,7 +476,7 @@ struct ContentView: View {
|
||||
}
|
||||
.onChange(of: viewModel.privateChats) { _ in
|
||||
if let peerID = privatePeer,
|
||||
let messages = viewModel.privateChats[peerID],
|
||||
let messages = viewModel.privateChats[PeerID(str: peerID)],
|
||||
!messages.isEmpty {
|
||||
// If the newest private message is from me, always scroll
|
||||
let lastMsg = messages.last!
|
||||
@@ -541,7 +531,7 @@ struct ContentView: View {
|
||||
}
|
||||
.onAppear {
|
||||
// Also check when view appears
|
||||
if let peerID = privatePeer {
|
||||
if let peerID = PeerID(str: privatePeer) {
|
||||
// Try multiple times to ensure read receipts are sent
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
|
||||
@@ -794,7 +784,7 @@ struct ContentView: View {
|
||||
case "user":
|
||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||
selectedMessageSenderID = peerID
|
||||
selectedMessageSenderID = peerID.id
|
||||
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||
@@ -842,10 +832,10 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func scrollToBottom(on proxy: ScrollViewProxy,
|
||||
privatePeer: PeerID?,
|
||||
privatePeer: String?,
|
||||
isAtBottom: Binding<Bool>) {
|
||||
let targetID: String? = {
|
||||
if let peer = privatePeer,
|
||||
if let peer = PeerID(str: privatePeer),
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
@@ -871,7 +861,7 @@ struct ContentView: View {
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
let secondTarget: String? = {
|
||||
if let peer = privatePeer,
|
||||
if let peer = PeerID(str: privatePeer),
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
@@ -922,53 +912,6 @@ struct ContentView: View {
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
// Present image picker from sheet context when IN a sheet (parent-child pattern)
|
||||
#if os(iOS)
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
showImagePicker = false
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||
showImagePicker = false
|
||||
if let image = image {
|
||||
Task {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run {
|
||||
viewModel.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showMacImagePicker) {
|
||||
MacImagePickerView { url in
|
||||
showMacImagePicker = false
|
||||
if let url = url {
|
||||
Task {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run {
|
||||
viewModel.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - People Sheet Views
|
||||
@@ -1055,14 +998,14 @@ struct ContentView: View {
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
onTapPeer: { peerID in
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
||||
showSidebar = true
|
||||
},
|
||||
onToggleFavorite: { peerID in
|
||||
viewModel.toggleFavorite(peerID: peerID)
|
||||
viewModel.toggleFavorite(peerID: PeerID(str: peerID))
|
||||
},
|
||||
onShowFingerprint: { peerID in
|
||||
viewModel.showFingerprint(for: peerID)
|
||||
viewModel.showFingerprint(for: PeerID(str: peerID))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1077,7 +1020,7 @@ struct ContentView: View {
|
||||
|
||||
private var privateChatSheetView: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let privatePeerID = viewModel.selectedPrivateChatPeer {
|
||||
if let privatePeerID = viewModel.selectedPrivateChatPeer?.id {
|
||||
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
@@ -1101,11 +1044,12 @@ struct ContentView: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
||||
let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID)
|
||||
let peerID = PeerID(str: headerContext.headerPeerID)
|
||||
let isFavorite = viewModel.isFavorite(peerID: peerID)
|
||||
|
||||
if !privatePeerID.isGeoDM {
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
Button(action: {
|
||||
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
|
||||
viewModel.toggleFavorite(peerID: peerID)
|
||||
}) {
|
||||
Image(systemName: isFavorite ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
@@ -1144,7 +1088,7 @@ struct ContentView: View {
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate)
|
||||
.background(backgroundColor)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
Divider()
|
||||
@@ -1166,9 +1110,9 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View {
|
||||
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: String) -> some View {
|
||||
Button(action: {
|
||||
viewModel.showFingerprint(for: context.headerPeerID)
|
||||
viewModel.showFingerprint(for: PeerID(str: context.headerPeerID))
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
if let connectionState = context.peer?.connectionState {
|
||||
@@ -1191,7 +1135,7 @@ struct ContentView: View {
|
||||
case .offline:
|
||||
EmptyView()
|
||||
}
|
||||
} else if viewModel.meshService.isPeerReachable(context.headerPeerID) {
|
||||
} else if viewModel.meshService.isPeerReachable(PeerID(str: context.headerPeerID)) {
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
@@ -1201,7 +1145,7 @@ struct ContentView: View {
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
} else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) {
|
||||
} else if viewModel.meshService.isPeerConnected(PeerID(str: context.headerPeerID)) || viewModel.connectedPeers.contains(PeerID(str: context.headerPeerID)) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
@@ -1212,9 +1156,14 @@ struct ContentView: View {
|
||||
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
if !privatePeerID.isGeoDM {
|
||||
let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
let statusPeerID: String = {
|
||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
return short.id
|
||||
}
|
||||
return context.headerPeerID
|
||||
}()
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
@@ -1246,27 +1195,33 @@ struct ContentView: View {
|
||||
.frame(height: headerHeight)
|
||||
}
|
||||
|
||||
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
|
||||
let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||
private func makePrivateHeaderContext(for privatePeerID: String) -> PrivateHeaderContext {
|
||||
let headerPeerID: String = {
|
||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
return short.id
|
||||
}
|
||||
return privatePeerID
|
||||
}()
|
||||
|
||||
let peer = viewModel.getPeer(byID: PeerID(str: headerPeerID))
|
||||
|
||||
let displayName: String = {
|
||||
if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel {
|
||||
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
||||
if privatePeerID.hasPrefix("nostr_"), case .location(let ch) = locationManager.selectedChannel {
|
||||
let disp = viewModel.geohashDisplayName(for: PeerID(str: privatePeerID))
|
||||
return "#\(ch.geohash)/@\(disp)"
|
||||
}
|
||||
if let name = peer?.displayName { return name }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()),
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
if headerPeerID.id.count == 16 {
|
||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if headerPeerID.count == 16 {
|
||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
||||
if let id = candidates.first,
|
||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
} else if let keyData = headerPeerID.noiseKey {
|
||||
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
||||
let fp = keyData.sha256Fingerprint()
|
||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
@@ -1278,7 +1233,7 @@ struct ContentView: View {
|
||||
|
||||
let isNostrAvailable: Bool = {
|
||||
guard let connectionState = peer?.connectionState else {
|
||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||
if let noiseKey = Data(hexString: headerPeerID),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
favoriteStatus.isMutual {
|
||||
return true
|
||||
@@ -1797,7 +1752,7 @@ private extension ContentView {
|
||||
|
||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
||||
allMessages: [BitchatMessage],
|
||||
privatePeer: PeerID?,
|
||||
privatePeer: String?,
|
||||
proxy: ScrollViewProxy) {
|
||||
let step = TransportConfig.uiWindowStepCount
|
||||
let contextKey: String = {
|
||||
@@ -1857,19 +1812,7 @@ private extension ContentView {
|
||||
}
|
||||
|
||||
private var shouldShowMediaControls: Bool {
|
||||
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
||||
return true
|
||||
}
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh:
|
||||
return true
|
||||
case .location:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowVoiceControl: Bool {
|
||||
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
||||
if viewModel.selectedPrivateChatPeer != nil {
|
||||
return true
|
||||
}
|
||||
switch locationManager.selectedChannel {
|
||||
@@ -1914,23 +1857,17 @@ private extension ContentView {
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var sendOrMicButton: some View {
|
||||
let hasText = !trimmedMessageText.isEmpty
|
||||
if shouldShowVoiceControl {
|
||||
ZStack {
|
||||
micButtonView
|
||||
.opacity(hasText ? 0 : 1)
|
||||
.allowsHitTesting(!hasText)
|
||||
sendButtonView(enabled: hasText)
|
||||
.opacity(hasText ? 1 : 0)
|
||||
.allowsHitTesting(hasText)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
} else {
|
||||
return ZStack {
|
||||
micButtonView
|
||||
.opacity(hasText ? 0 : 1)
|
||||
.allowsHitTesting(!hasText)
|
||||
sendButtonView(enabled: hasText)
|
||||
.frame(width: 36, height: 36)
|
||||
.opacity(hasText ? 1 : 0)
|
||||
.allowsHitTesting(hasText)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
|
||||
private var micButtonView: some View {
|
||||
@@ -1983,7 +1920,6 @@ private extension ContentView {
|
||||
}
|
||||
|
||||
func startVoiceRecording() {
|
||||
guard shouldShowVoiceControl else { return }
|
||||
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
||||
isPreparingVoiceNote = true
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
struct FingerprintView: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
let peerID: PeerID
|
||||
let peerID: String
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@@ -65,12 +65,15 @@ struct FingerprintView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Prefer short mesh ID for session/encryption status
|
||||
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
|
||||
let statusPeerID: String = {
|
||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
|
||||
return peerID
|
||||
}()
|
||||
// Resolve a friendly name
|
||||
let peerNickname: String = {
|
||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||
if let data = peerID.noiseKey {
|
||||
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
let fp = data.sha256Fingerprint()
|
||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||
@@ -81,7 +84,7 @@ struct FingerprintView: View {
|
||||
return Strings.unknownPeer()
|
||||
}()
|
||||
// Accurate encryption state based on short ID session
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
||||
|
||||
HStack {
|
||||
if let icon = encryptionStatus.icon {
|
||||
@@ -112,7 +115,7 @@ struct FingerprintView: View {
|
||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||
if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
|
||||
Text(formatFingerprint(fingerprint))
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
@@ -173,6 +176,7 @@ struct FingerprintView: View {
|
||||
// Verification status
|
||||
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
||||
let isVerified = encryptionStatus == .noiseVerified
|
||||
let peerID = PeerID(str: peerID)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||
@@ -236,6 +240,8 @@ struct FingerprintView: View {
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(backgroundColor)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private func formatFingerprint(_ fingerprint: String) -> String {
|
||||
|
||||
@@ -125,6 +125,9 @@ struct LocationChannelsSheet: View {
|
||||
.navigationTitle("")
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
|
||||
@@ -78,6 +78,9 @@ struct LocationNotesView: View {
|
||||
.navigationTitle("")
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
#endif
|
||||
.background(backgroundColor)
|
||||
.onDisappear { manager.cancel() }
|
||||
.onChange(of: geohash) { newValue in
|
||||
|
||||
@@ -4,9 +4,9 @@ struct MeshPeerList: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
let onTapPeer: (PeerID) -> Void
|
||||
let onToggleFavorite: (PeerID) -> Void
|
||||
let onShowFingerprint: (PeerID) -> Void
|
||||
let onTapPeer: (String) -> Void
|
||||
let onToggleFavorite: (String) -> Void
|
||||
let onShowFingerprint: (String) -> Void
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
Button(action: { onToggleFavorite(peer.peerID) }) {
|
||||
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||
@@ -142,8 +142,8 @@ struct MeshPeerList: View {
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, idx == 0 ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
|
||||
@@ -388,6 +388,10 @@ struct VerificationSheetView: View {
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
.onDisappear { showingScanner = false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,5 +298,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
||||
}
|
||||
|
||||
@@ -186,11 +186,11 @@ struct PrivateChatE2ETests {
|
||||
// Bob relays private messages for Charlie
|
||||
bob.packetDeliveryHandler = { packet in
|
||||
if let recipientID = packet.recipientID,
|
||||
PeerID(data: recipientID) == charlie.peerID {
|
||||
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
||||
// Relay to Charlie
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl = packet.ttl - 1
|
||||
charlie.simulateIncomingPacket(relayPacket)
|
||||
self.charlie.simulateIncomingPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
|
||||
|
||||
if let message = BitchatMessage(packet.payload) {
|
||||
// Don't relay own messages
|
||||
guard message.senderPeerID != node.peerID else { return }
|
||||
guard message.senderPeerID?.id != node.peerID else { return }
|
||||
|
||||
// Create relay message
|
||||
let relayMessage = BitchatMessage(
|
||||
|
||||
@@ -209,7 +209,7 @@ extension FragmentationTests {
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
publicMessages.append((peerID, nickname, content))
|
||||
}
|
||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||
|
||||
@@ -125,138 +125,16 @@ struct GossipSyncManagerTests {
|
||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||
}
|
||||
|
||||
@Test func maintenanceEmitsTypedSyncRequests() throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.seenCapacity = 10
|
||||
config.fragmentCapacity = 5
|
||||
config.fileTransferCapacity = 4
|
||||
config.messageSyncIntervalSeconds = 1
|
||||
config.fragmentSyncIntervalSeconds = 1
|
||||
config.fileTransferSyncIntervalSeconds = 1
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "1122334455667788"))
|
||||
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
let announcePacket = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data(),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let messagePacket = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0x01]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let fragmentPacket = BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0xAA]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let filePacket = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0xBB]),
|
||||
signature: nil,
|
||||
ttl: 1,
|
||||
version: 2
|
||||
)
|
||||
|
||||
manager.onPublicPacketSeen(announcePacket)
|
||||
manager.onPublicPacketSeen(messagePacket)
|
||||
manager.onPublicPacketSeen(fragmentPacket)
|
||||
manager.onPublicPacketSeen(filePacket)
|
||||
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 3)
|
||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
||||
#expect(decoded.count == 3)
|
||||
#expect(decoded[0].types == .publicMessages)
|
||||
#expect(decoded[1].types == .fragment)
|
||||
#expect(decoded[2].types == .fileTransfer)
|
||||
}
|
||||
|
||||
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.seenCapacity = 5
|
||||
config.fragmentCapacity = 5
|
||||
config.fileTransferCapacity = 0
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
|
||||
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
let messagePacket = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0x10]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
let fragmentPacket = BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: now,
|
||||
payload: Data([0x20]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
manager.onPublicPacketSeen(messagePacket)
|
||||
manager.onPublicPacketSeen(fragmentPacket)
|
||||
|
||||
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await sleep(0.01)
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
var onSend: (() -> Void)?
|
||||
private(set) var lastPacket: BitchatPacket?
|
||||
private(set) var packets: [BitchatPacket] = []
|
||||
private let lock = NSLock()
|
||||
|
||||
func sendPacket(_ packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
lastPacket = packet
|
||||
packets.append(packet)
|
||||
lock.unlock()
|
||||
onSend?()
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
//
|
||||
// InputValidatorTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct InputValidatorTests {
|
||||
|
||||
// MARK: - Basic Validation Tests
|
||||
|
||||
@Test func validStringPassesValidation() throws {
|
||||
let result = InputValidator.validateUserString("Hello World", maxLength: 100)
|
||||
#expect(result == "Hello World")
|
||||
}
|
||||
|
||||
@Test func emptyStringReturnsNil() throws {
|
||||
let result = InputValidator.validateUserString("", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyStringReturnsNil() throws {
|
||||
let result = InputValidator.validateUserString(" \n\t ", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func stringExceedingMaxLengthReturnsNil() throws {
|
||||
let longString = String(repeating: "a", count: 101)
|
||||
let result = InputValidator.validateUserString(longString, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func stringAtMaxLengthIsAccepted() throws {
|
||||
let exactString = String(repeating: "a", count: 100)
|
||||
let result = InputValidator.validateUserString(exactString, maxLength: 100)
|
||||
#expect(result == exactString)
|
||||
}
|
||||
|
||||
@Test func whitespaceIsTrimmed() throws {
|
||||
let result = InputValidator.validateUserString(" Hello ", maxLength: 100)
|
||||
#expect(result == "Hello")
|
||||
}
|
||||
|
||||
// MARK: - Control Character Tests
|
||||
|
||||
@Test func nullCharacterIsRejected() throws {
|
||||
let stringWithNull = "Hello\u{0000}World"
|
||||
let result = InputValidator.validateUserString(stringWithNull, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func bellCharacterIsRejected() throws {
|
||||
let stringWithBell = "Hello\u{0007}World"
|
||||
let result = InputValidator.validateUserString(stringWithBell, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func backspaceCharacterIsRejected() throws {
|
||||
let stringWithBackspace = "Hello\u{0008}World"
|
||||
let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func escapeCharacterIsRejected() throws {
|
||||
let stringWithEscape = "Hello\u{001B}World"
|
||||
let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func deleteCharacterIsRejected() throws {
|
||||
let stringWithDelete = "Hello\u{007F}World"
|
||||
let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func multipleControlCharactersAreRejected() throws {
|
||||
let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World"
|
||||
let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
// MARK: - Unicode and Special Character Tests
|
||||
|
||||
@Test func emojiIsAccepted() throws {
|
||||
let result = InputValidator.validateUserString("Hello 👋 World", maxLength: 100)
|
||||
#expect(result == "Hello 👋 World")
|
||||
}
|
||||
|
||||
@Test func unicodeCharactersAreAccepted() throws {
|
||||
let result = InputValidator.validateUserString("Hello 世界 مرحبا", maxLength: 100)
|
||||
#expect(result == "Hello 世界 مرحبا")
|
||||
}
|
||||
|
||||
@Test func specialCharactersAreAccepted() throws {
|
||||
let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100)
|
||||
#expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?")
|
||||
}
|
||||
|
||||
// MARK: - Nickname Validation Tests
|
||||
|
||||
@Test func validNicknameIsAccepted() throws {
|
||||
let result = InputValidator.validateNickname("Alice")
|
||||
#expect(result == "Alice")
|
||||
}
|
||||
|
||||
@Test func nicknameWithEmojiIsAccepted() throws {
|
||||
let result = InputValidator.validateNickname("Alice 🚀")
|
||||
#expect(result == "Alice 🚀")
|
||||
}
|
||||
|
||||
@Test func nicknameTooLongIsRejected() throws {
|
||||
let longNickname = String(repeating: "a", count: 51)
|
||||
let result = InputValidator.validateNickname(longNickname)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func nicknameAtMaxLengthIsAccepted() throws {
|
||||
let exactNickname = String(repeating: "a", count: 50)
|
||||
let result = InputValidator.validateNickname(exactNickname)
|
||||
#expect(result == exactNickname)
|
||||
}
|
||||
|
||||
@Test func nicknameWithControlCharacterIsRejected() throws {
|
||||
let result = InputValidator.validateNickname("Alice\u{0000}")
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Validation Tests
|
||||
|
||||
@Test func currentTimestampIsValid() throws {
|
||||
let now = Date()
|
||||
let result = InputValidator.validateTimestamp(now)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func timestampWithinOneHourIsValid() throws {
|
||||
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
|
||||
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func timestampTwoHoursAgoIsInvalid() throws {
|
||||
let twoHoursAgo = Date().addingTimeInterval(-2 * 3600)
|
||||
let result = InputValidator.validateTimestamp(twoHoursAgo)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func timestampTwoHoursInFutureIsInvalid() throws {
|
||||
let twoHoursFromNow = Date().addingTimeInterval(2 * 3600)
|
||||
let result = InputValidator.validateTimestamp(twoHoursFromNow)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func timestampAtOneHourBoundaryIsValid() throws {
|
||||
// Just slightly within the one-hour window
|
||||
let almostOneHourAgo = Date().addingTimeInterval(-3599)
|
||||
let result = InputValidator.validateTimestamp(almostOneHourAgo)
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
@Test func singleCharacterStringIsAccepted() throws {
|
||||
let result = InputValidator.validateUserString("a", maxLength: 100)
|
||||
#expect(result == "a")
|
||||
}
|
||||
|
||||
@Test func stringWithOnlyNewlinesIsRejected() throws {
|
||||
let result = InputValidator.validateUserString("\n\n\n", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func stringWithMixedWhitespaceIsTrimmed() throws {
|
||||
let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100)
|
||||
#expect(result == "Hello")
|
||||
}
|
||||
|
||||
@Test func stringWithLeadingControlCharacterIsRejected() throws {
|
||||
let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func stringWithTrailingControlCharacterIsRejected() throws {
|
||||
let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100)
|
||||
#expect(result == nil)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//
|
||||
// MimeTypeTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - MimeType Mapping and Signature Tests
|
||||
|
||||
struct MimeTypeTests {
|
||||
|
||||
// MARK: MIME → Enum Parsing + Default Extension
|
||||
@Test(arguments: [
|
||||
("image/jpeg", MimeType.jpeg, "jpg"),
|
||||
("image/jpg", MimeType.jpeg, "jpg"),
|
||||
("image/png", MimeType.png, "png"),
|
||||
("image/gif", MimeType.gif, "gif"),
|
||||
("image/webp", MimeType.webp, "webp"),
|
||||
("audio/mp4", MimeType.mp4Audio, "m4a"),
|
||||
("audio/m4a", MimeType.m4a, "m4a"),
|
||||
("audio/aac", MimeType.aac, "m4a"),
|
||||
("audio/mpeg", MimeType.mpeg, "mp3"),
|
||||
("audio/mp3", MimeType.mp3, "mp3"),
|
||||
("audio/wav", MimeType.wav, "wav"),
|
||||
("audio/x-wav", MimeType.xWav, "wav"),
|
||||
("audio/ogg", MimeType.ogg, "ogg"),
|
||||
("application/pdf", MimeType.pdf, "pdf"),
|
||||
("application/octet-stream", MimeType.octetStream, "bin")
|
||||
])
|
||||
func mimeTypeParsingAndExtensions(
|
||||
mimeString: String,
|
||||
expectedType: MimeType,
|
||||
expectedExt: String
|
||||
) throws {
|
||||
guard let mime = MimeType(mimeString) else {
|
||||
Issue.record("Failed to parse \(mimeString)")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)")
|
||||
#expect(mime.mimeString == expectedType.mimeString)
|
||||
#expect(mime.defaultExtension == expectedExt)
|
||||
#expect(mime.isAllowed)
|
||||
}
|
||||
|
||||
// MARK: - File Signature Validation
|
||||
@Test(arguments: [
|
||||
// === Image types ===
|
||||
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
|
||||
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
|
||||
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
|
||||
|
||||
// === Audio types ===
|
||||
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
|
||||
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
||||
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
|
||||
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
|
||||
|
||||
// === Application types ===
|
||||
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
|
||||
])
|
||||
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
|
||||
let data = Data(bytes)
|
||||
#expect(mime.matches(data: data),
|
||||
"Expected \(mime.mimeString) to match its signature")
|
||||
}
|
||||
|
||||
// MARK: - Negative Tests
|
||||
@Test func invalidDataDoesNotMatch() throws {
|
||||
let badData = Data(repeating: 0x00, count: 16)
|
||||
for mime in MimeType.allCases where mime != .octetStream {
|
||||
#expect(!mime.matches(data: badData),
|
||||
"Unexpectedly matched \(mime.mimeString) with zeroed data")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Octet-stream (generic binary)
|
||||
@Test func octetStreamAlwaysMatches() throws {
|
||||
let randomData = Data([0x00, 0x11, 0x22, 0x33])
|
||||
#expect(MimeType.octetStream.matches(data: randomData),
|
||||
"application/octet-stream should always be considered valid")
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,10 @@ final class MockBLEService: NSObject {
|
||||
let target = bus.service(for: recipientPeerID) {
|
||||
target.simulateIncomingPacket(packet)
|
||||
} else {
|
||||
// Not directly connected: deliver to neighbors for relay
|
||||
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
||||
if let target = bus.service(for: recipientPeerID) {
|
||||
target.simulateIncomingPacket(packet)
|
||||
}
|
||||
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
||||
neighbor.simulateIncomingPacket(packet)
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ struct NostrProtocolTests {
|
||||
|
||||
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||
let messageID = "TEST-MSG-DELIVERED-1"
|
||||
let senderPeerID = PeerID(str: "0123456789abcdef") // 8-byte hex peer ID
|
||||
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
||||
|
||||
let embedded = try #require(
|
||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
||||
@@ -176,7 +176,7 @@ struct NostrProtocolTests {
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let messageID = "TEST-MSG-READ-1"
|
||||
let senderPeerID = PeerID(str: "fedcba9876543210") // 8-byte hex peer ID
|
||||
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
||||
let embedded = try #require(
|
||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
||||
"Failed to embed read ack"
|
||||
|
||||
@@ -273,7 +273,7 @@ struct PeerIDTests {
|
||||
@Test func comparable_sorting_and_equality() {
|
||||
let p1 = PeerID(str: "aaa")
|
||||
let p2 = PeerID(str: "bbb")
|
||||
let p3 = PeerID(str: "BBB")
|
||||
let p3 = PeerID(str: "bbb")
|
||||
|
||||
#expect(p1 < p2)
|
||||
#expect(p2 >= p1)
|
||||
@@ -284,18 +284,44 @@ struct PeerIDTests {
|
||||
}
|
||||
|
||||
@Test func equality() {
|
||||
let peerID = PeerID(str: "aaa")
|
||||
let string = "aaa"
|
||||
let peerID = PeerID(str: string)
|
||||
let badString = "bbb"
|
||||
|
||||
// PeerID == String
|
||||
#expect(peerID == string)
|
||||
#expect(peerID == Optional(string))
|
||||
#expect(Optional(peerID) == string)
|
||||
#expect(Optional(peerID) == Optional(string))
|
||||
|
||||
// PeerID != String
|
||||
#expect(peerID != badString)
|
||||
#expect(peerID != Optional(badString))
|
||||
#expect(Optional(peerID) != badString)
|
||||
#expect(Optional(peerID) != Optional(badString))
|
||||
|
||||
// String == PeerID
|
||||
#expect(string == peerID)
|
||||
#expect(Optional(string) == peerID)
|
||||
#expect(string == Optional(peerID))
|
||||
#expect(Optional(string) == Optional(peerID))
|
||||
|
||||
// String != PeerID
|
||||
#expect(badString != peerID)
|
||||
#expect(Optional(badString) != peerID)
|
||||
#expect(badString != Optional(peerID))
|
||||
#expect(Optional(badString) != Optional(peerID))
|
||||
|
||||
// Regular PeerID <> PeerID
|
||||
#expect(peerID == PeerID(str: "AAA"))
|
||||
#expect(peerID == Optional(PeerID(str: "AAA")))
|
||||
#expect(PeerID(str: "AAA") == peerID)
|
||||
#expect(Optional(PeerID(str: "AAA")) == Optional(peerID))
|
||||
#expect(peerID == PeerID(str: "aaa"))
|
||||
#expect(peerID == Optional(PeerID(str: "aaa")))
|
||||
#expect(PeerID(str: "aaa") == peerID)
|
||||
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
|
||||
|
||||
#expect(peerID != PeerID(str: "BBB"))
|
||||
#expect(peerID != Optional(PeerID(str: "BBB")))
|
||||
#expect(PeerID(str: "BBB") != peerID)
|
||||
#expect(Optional(PeerID(str: "BBB")) != Optional(peerID))
|
||||
#expect(peerID != PeerID(str: "bbb"))
|
||||
#expect(peerID != Optional(PeerID(str: "bbb")))
|
||||
#expect(PeerID(str: "bbb") != peerID)
|
||||
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
|
||||
}
|
||||
|
||||
// MARK: - Computed properties
|
||||
|
||||
@@ -18,10 +18,6 @@ let package = Package(
|
||||
.target(
|
||||
name: "BitLogger",
|
||||
path: "Sources"
|
||||
),
|
||||
.testTarget(
|
||||
name: "BitLoggerTests",
|
||||
dependencies: ["BitLogger"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -68,6 +68,22 @@ public final class SecureLogger {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Cached Regex Patterns
|
||||
|
||||
private static let fingerprintPattern = #/[a-fA-F0-9]{64}/#
|
||||
private static let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
|
||||
private static let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
||||
private static let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
|
||||
|
||||
// MARK: - Sanitization Cache
|
||||
|
||||
private static let sanitizationCache: NSCache<NSString, NSString> = {
|
||||
let cache = NSCache<NSString, NSString>()
|
||||
cache.countLimit = 100 // Keep last 100 sanitized strings
|
||||
return cache
|
||||
}()
|
||||
private static let cacheQueue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
|
||||
|
||||
// MARK: - Log Levels
|
||||
|
||||
enum LogLevel {
|
||||
@@ -145,8 +161,8 @@ public extension SecureLogger {
|
||||
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
||||
file: String = #file, line: Int = #line, function: String = #function) {
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let sanitized = context().sanitized()
|
||||
let errorDesc = error.localizedDescription.sanitized()
|
||||
let sanitized = sanitize(context())
|
||||
let errorDesc = sanitize(error.localizedDescription)
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||
@@ -170,15 +186,15 @@ public extension SecureLogger {
|
||||
var message: String {
|
||||
switch self {
|
||||
case .handshakeStarted(let peerID):
|
||||
return "Handshake started with peer: \(peerID.sanitized())"
|
||||
return "Handshake started with peer: \(sanitize(peerID))"
|
||||
case .handshakeCompleted(let peerID):
|
||||
return "Handshake completed with peer: \(peerID.sanitized())"
|
||||
return "Handshake completed with peer: \(sanitize(peerID))"
|
||||
case .handshakeFailed(let peerID, let error):
|
||||
return "Handshake failed with peer: \(peerID.sanitized()), error: \(error)"
|
||||
return "Handshake failed with peer: \(sanitize(peerID)), error: \(error)"
|
||||
case .sessionExpired(let peerID):
|
||||
return "Session expired for peer: \(peerID.sanitized())"
|
||||
return "Session expired for peer: \(sanitize(peerID))"
|
||||
case .authenticationFailed(let peerID):
|
||||
return "Authentication failed for peer: \(peerID.sanitized())"
|
||||
return "Authentication failed for peer: \(sanitize(peerID))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +249,7 @@ private extension SecureLogger {
|
||||
file: String, line: Int, function: String) {
|
||||
guard shouldLog(level) else { return }
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let sanitized = "\(location) \(message())".sanitized()
|
||||
let sanitized = sanitize("\(location) \(message())")
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||
@@ -266,6 +282,58 @@ private extension SecureLogger {
|
||||
let timestamp = timestampFormatter.string(from: Date())
|
||||
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
||||
}
|
||||
|
||||
/// Sanitize strings to remove potentially sensitive data
|
||||
static func sanitize(_ input: String) -> String {
|
||||
let key = input as NSString
|
||||
|
||||
// Check cache first
|
||||
var cachedValue: String?
|
||||
cacheQueue.sync {
|
||||
cachedValue = sanitizationCache.object(forKey: key) as String?
|
||||
}
|
||||
|
||||
if let cached = cachedValue {
|
||||
return cached
|
||||
}
|
||||
|
||||
// Perform sanitization
|
||||
var sanitized = input
|
||||
|
||||
// Remove full fingerprints (keep first 8 chars for debugging)
|
||||
sanitized = sanitized.replacing(fingerprintPattern) { match in
|
||||
let fingerprint = String(match.output)
|
||||
return String(fingerprint.prefix(8)) + "..."
|
||||
}
|
||||
|
||||
// Remove base64 encoded data that might be keys
|
||||
sanitized = sanitized.replacing(base64Pattern) { _ in
|
||||
"<base64-data>"
|
||||
}
|
||||
|
||||
// Remove potential passwords (assuming they're in quotes or after "password:")
|
||||
sanitized = sanitized.replacing(passwordPattern) { _ in
|
||||
"password: <redacted>"
|
||||
}
|
||||
|
||||
// Truncate peer IDs to first 8 characters
|
||||
sanitized = sanitized.replacing(peerIDPattern) { match in
|
||||
"peerID: \(match.1)..."
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cacheQueue.sync {
|
||||
sanitizationCache.setObject(sanitized as NSString, forKey: key)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/// Sanitize individual values
|
||||
static func sanitize<T>(_ value: T) -> String {
|
||||
let stringValue = String(describing: value)
|
||||
return sanitize(stringValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration Helper
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
//
|
||||
// String+Sanitization.swift
|
||||
// BitLogger
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Sanitize strings to remove potentially sensitive data
|
||||
func sanitized() -> String {
|
||||
let key = self as NSString
|
||||
|
||||
// Check cache first
|
||||
if let cached = Self.queue.sync(execute: { Self.cache.object(forKey: key) }) {
|
||||
return cached as String
|
||||
}
|
||||
|
||||
var sanitized = self
|
||||
|
||||
// Remove full fingerprints (keep first 8 chars for debugging)
|
||||
let fingerprintPattern = #/[a-fA-F0-9]{64}/#
|
||||
sanitized = sanitized.replacing(fingerprintPattern) { match in
|
||||
let fingerprint = String(match.output)
|
||||
return String(fingerprint.prefix(8)) + "..."
|
||||
}
|
||||
|
||||
// Remove base64 encoded data that might be keys
|
||||
let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
|
||||
sanitized = sanitized.replacing(base64Pattern) { _ in
|
||||
"<base64-data>"
|
||||
}
|
||||
|
||||
// Remove potential passwords (assuming they're in quotes or after "password:")
|
||||
let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
||||
sanitized = sanitized.replacing(passwordPattern) { _ in
|
||||
"password: <redacted>"
|
||||
}
|
||||
|
||||
// Truncate peer IDs to first 8 characters
|
||||
let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
|
||||
sanitized = sanitized.replacing(peerIDPattern) { match in
|
||||
"peerID: \(match.1)..."
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
Self.queue.sync {
|
||||
Self.cache.setObject(sanitized as NSString, forKey: key)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache Helpers
|
||||
|
||||
private extension String {
|
||||
static let queue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
|
||||
|
||||
static let cache: NSCache<NSString, NSString> = {
|
||||
let cache = NSCache<NSString, NSString>()
|
||||
cache.countLimit = 100 // Keep last 100 sanitized strings
|
||||
return cache
|
||||
}()
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//
|
||||
// StringSanitizationTests.swift
|
||||
// BitLogger
|
||||
//
|
||||
// Created by Islam on 19/10/2025.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import BitLogger
|
||||
|
||||
struct StringSanitizationTests {
|
||||
|
||||
@Test("64-hex fingerprint is truncated to first 8 chars followed by ellipsis")
|
||||
func fingerprintTruncation() async throws {
|
||||
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
#expect(fingerprint.count == 64)
|
||||
|
||||
let input = "fingerprint=\(fingerprint)"
|
||||
let output = input.sanitized()
|
||||
|
||||
#expect(output.contains("fingerprint=01234567..."))
|
||||
// Ensure no full fingerprint remains
|
||||
#expect(output.contains(fingerprint) == false)
|
||||
}
|
||||
|
||||
@Test("Multiple fingerprints in a string are all truncated")
|
||||
func multipleFingerprintTruncation() async throws {
|
||||
let fp1 = String(repeating: "a", count: 64)
|
||||
let fp2 = String(repeating: "b", count: 64)
|
||||
let input = "fp1=\(fp1) fp2=\(fp2)"
|
||||
let output = input.sanitized()
|
||||
#expect(output.contains("fp1=aaaaaaaa..."))
|
||||
#expect(output.contains("fp2=bbbbbbbb..."))
|
||||
#expect(output.contains(fp1) == false)
|
||||
#expect(output.contains(fp2) == false)
|
||||
}
|
||||
|
||||
@Test("Base64-like long data is replaced with <base64-data>")
|
||||
func base64Replacement() async throws {
|
||||
// 44+ chars of base64 characters
|
||||
let base64ish = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE="
|
||||
let input = "payload=\(base64ish)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "payload=<base64-data>")
|
||||
}
|
||||
|
||||
@Test("Base64-like without padding is replaced with <base64-data>")
|
||||
func base64NoPaddingReplacement() async throws {
|
||||
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
#expect(base64ish.count >= 40)
|
||||
let input = "b64:\(base64ish)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "b64:<base64-data>")
|
||||
}
|
||||
|
||||
@Test("Short base64-like strings (below threshold) are not replaced")
|
||||
func shortBase64NotReplaced() async throws {
|
||||
let short = "QUJDREVGR0hJSktMTU5P" // < 40 chars
|
||||
let input = "payload=\(short)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == input)
|
||||
}
|
||||
|
||||
@Test("Password redaction for key:value formats", arguments: [
|
||||
"password: secret123",
|
||||
"password=secret123",
|
||||
"password = secret123",
|
||||
"password: 'secret123'",
|
||||
"password:\"secret123\"",
|
||||
"password='secret123'"
|
||||
])
|
||||
func passwordRedactionKeyValue(password: String) async throws {
|
||||
#expect(password.sanitized() == "password: <redacted>")
|
||||
}
|
||||
|
||||
@Test("Password redaction inside wider messages")
|
||||
func passwordRedactionInContext() async throws {
|
||||
let input = "user=john password: 'p@ssW0rd' attempt=1"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "user=john password: <redacted> attempt=1")
|
||||
}
|
||||
|
||||
@Test("PeerID is truncated to first 8 chars followed by ellipsis")
|
||||
func peerIDTruncation() async throws {
|
||||
let peer = "ABCDEF12GHIJKL34"
|
||||
let input = "peerID: \(peer)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "peerID: ABCDEF12...")
|
||||
}
|
||||
|
||||
@Test("PeerID not truncated when exactly 8 chars")
|
||||
func peerIDExactlyEightNotTruncated() async throws {
|
||||
let peer = "ABCDEF12"
|
||||
let input = "peerID: \(peer)"
|
||||
let output = input.sanitized()
|
||||
// Pattern only matches when there are more than 8 trailing chars, so unchanged
|
||||
#expect(output == input)
|
||||
}
|
||||
|
||||
@Test("Non-matching content remains unchanged")
|
||||
func nonMatchingUnchanged() async throws {
|
||||
let input = "Hello world 123 - nothing sensitive here."
|
||||
let output = input.sanitized()
|
||||
#expect(output == input)
|
||||
}
|
||||
|
||||
@Test("Idempotency: sanitizing twice yields same result")
|
||||
func idempotentSanitization() async throws {
|
||||
let input = """
|
||||
fingerprint=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
|
||||
password: "superSecret" \
|
||||
peerID: ZYXWVUT987654321 \
|
||||
payload=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
|
||||
"""
|
||||
let once = input.sanitized()
|
||||
let twice = once.sanitized()
|
||||
#expect(once == twice)
|
||||
}
|
||||
|
||||
@Test("Mixed content: all rules apply in a single string")
|
||||
func mixedContent() async throws {
|
||||
let fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
let peer = "PEERID01EXTRA"
|
||||
let input = "fp=\(fingerprint) password='x' peerID: \(peer) data=\(base64ish)"
|
||||
let output = input.sanitized()
|
||||
#expect(output.contains("fp=fedcba98..."))
|
||||
#expect(output.contains("password: <redacted>"))
|
||||
#expect(output.contains("peerID: PEERID01..."))
|
||||
#expect(output.contains("data=<base64-data>"))
|
||||
#expect(output.contains(fingerprint) == false)
|
||||
#expect(output.contains(base64ish) == false)
|
||||
}
|
||||
|
||||
@Test("Cache returns consistent result for repeated inputs")
|
||||
func cacheHitConsistency() async throws {
|
||||
let input = "password: hunter2"
|
||||
let first = input.sanitized()
|
||||
let second = input.sanitized()
|
||||
#expect(first == "password: <redacted>")
|
||||
#expect(first == second)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user