mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 20:05:22 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c4e586fc | ||
|
|
83779240ae | ||
|
|
5034732515 | ||
|
|
b6ce4fae43 | ||
|
|
98fa02cc16 | ||
|
|
0812cafd55 | ||
|
|
450955525c | ||
|
|
475bc70c71 | ||
|
|
af6136c01c | ||
|
|
b839ce5f6c | ||
|
|
0776c9813c | ||
|
|
0dd999af6b | ||
|
|
c3a1af7023 | ||
|
|
880813f256 | ||
|
|
64fb634166 | ||
|
|
13b19fb8eb | ||
|
|
d4967ae9c3 | ||
|
|
435744a977 | ||
|
|
70caa9e24a | ||
|
|
5084f87fe5 | ||
|
|
8f56e4f0fb | ||
|
|
aca44f9f55 | ||
|
|
3f00cf9467 | ||
|
|
40e54a5120 |
@@ -7,6 +7,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-relay-data:
|
update-relay-data:
|
||||||
@@ -17,24 +18,54 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Fetch GeoRelays
|
- name: Fetch GeoRelays
|
||||||
run: |
|
run: |
|
||||||
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Configure git
|
||||||
id: git-check
|
|
||||||
run: |
|
run: |
|
||||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
git config user.email "action@github.com"
|
||||||
|
git config user.name "GitHub Action"
|
||||||
- name: Commit and push changes
|
|
||||||
if: steps.git-check.outputs.changes == 'true'
|
- name: Create update branch if changes
|
||||||
|
id: create_branch
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@github.com"
|
# exit early if no changes
|
||||||
git config --local user.name "GitHub Action"
|
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# branch name with timestamp
|
||||||
|
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
|
||||||
git add relays/online_relays_gps.csv
|
git add relays/online_relays_gps.csv
|
||||||
git commit -m "Automated update of relay data - $(date -u)"
|
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||||
git push
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
env:
|
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
- name: Push branch
|
||||||
|
if: steps.create_branch.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
if: steps.create_branch.outputs.changed == 'true'
|
||||||
|
uses: peter-evans/create-pull-request@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: Automated update of relay data
|
||||||
|
branch: ${{ steps.create_branch.outputs.branch }}
|
||||||
|
base: main
|
||||||
|
title: Automated update of relay data
|
||||||
|
body: |
|
||||||
|
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
||||||
|
labels: automated, georelays
|
||||||
|
|
||||||
|
- name: No changes
|
||||||
|
if: steps.create_branch.outputs.changed != 'true'
|
||||||
|
run: echo "No changes to relays/online_relays_gps.csv"
|
||||||
@@ -98,8 +98,8 @@
|
|||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
key = "BITCHAT_LOG_LEVEL"
|
||||||
value = ""
|
value = "debug"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
// Don't show notification if the private chat is already open
|
// Don't show notification if the private chat is already open
|
||||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||||
completionHandler([])
|
completionHandler([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.85
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 60_000
|
private static let targetImageBytes: Int = 45_000
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
// Security H1: Check file size BEFORE reading into memory
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
guard let fileSize = attrs[.size] as? Int else {
|
||||||
@@ -38,7 +38,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
@@ -106,7 +106,7 @@ enum ImageUtils {
|
|||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
|
private let maxRecordingDuration: TimeInterval = 120
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
@@ -75,14 +76,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
AVSampleRateKey: 16_000,
|
AVSampleRateKey: 16_000,
|
||||||
AVNumberOfChannelsKey: 1,
|
AVNumberOfChannelsKey: 1,
|
||||||
AVEncoderBitRateKey: 20_000
|
AVEncoderBitRateKey: 16_000
|
||||||
]
|
]
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
audioRecorder.delegate = self
|
audioRecorder.delegate = self
|
||||||
audioRecorder.isMeteringEnabled = true
|
audioRecorder.isMeteringEnabled = true
|
||||||
audioRecorder.prepareToRecord()
|
audioRecorder.prepareToRecord()
|
||||||
audioRecorder.record()
|
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||||
|
|
||||||
recorder = audioRecorder
|
recorder = audioRecorder
|
||||||
currentURL = outputURL
|
currentURL = outputURL
|
||||||
|
|||||||
@@ -6268,10 +6268,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Choose an image" : {
|
|
||||||
"comment" : "A label displayed above a button that allows the user to choose an image to send.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"close" : {
|
"close" : {
|
||||||
"comment" : "Button to dismiss fullscreen media viewer",
|
"comment" : "Button to dismiss fullscreen media viewer",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -35705,7 +35701,545 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"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"
|
"version" : "1.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
|
|||||||
// Private so the callers have to go through a convenience init
|
// Private so the callers have to go through a convenience init
|
||||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
self.bare = String(bare)
|
self.bare = String(bare).lowercased()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +76,12 @@ extension PeerID {
|
|||||||
init(hexData: Data) {
|
init(hexData: Data) {
|
||||||
self.init(str: hexData.hexEncodedString())
|
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
|
// MARK: - Noise Public Key Helpers
|
||||||
@@ -191,9 +197,7 @@ extension PeerID: Comparable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - String Interop Helpers
|
// MARK: - CustomStringConvertible
|
||||||
|
|
||||||
// MARK: CustomStringConvertible
|
|
||||||
|
|
||||||
extension PeerID: CustomStringConvertible {
|
extension PeerID: CustomStringConvertible {
|
||||||
/// So it returns the actual `id` like before even inside another String
|
/// So it returns the actual `id` like before even inside another String
|
||||||
@@ -201,17 +205,3 @@ extension PeerID: CustomStringConvertible {
|
|||||||
id
|
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 {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
let receiptID: String
|
let receiptID: String
|
||||||
var readerID: String // Who read it
|
var readerID: PeerID // Who read it
|
||||||
let readerNickname: String
|
let readerNickname: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = UUID().uuidString
|
self.receiptID = UUID().uuidString
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For binary decoding
|
// For binary decoding
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = receiptID
|
self.receiptID = receiptID
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
|
|||||||
data.appendUUID(receiptID)
|
data.appendUUID(receiptID)
|
||||||
// ReaderID as 8-byte hex string
|
// ReaderID as 8-byte hex string
|
||||||
var readerData = Data()
|
var readerData = Data()
|
||||||
var tempID = readerID
|
var tempID = readerID.id
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
|
|||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = readerIDData.hexEncodedString()
|
let readerID = PeerID(hexData: readerIDData)
|
||||||
guard PeerID(str: readerID).isValid else { return nil }
|
guard readerID.isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ struct RequestSyncPacket {
|
|||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
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 {
|
func encode() -> Data {
|
||||||
var out = Data()
|
var out = Data()
|
||||||
@@ -25,6 +33,9 @@ struct RequestSyncPacket {
|
|||||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||||
// data
|
// data
|
||||||
putTLV(0x03, data)
|
putTLV(0x03, data)
|
||||||
|
if let typesData = types?.toData() {
|
||||||
|
putTLV(0x04, typesData)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +44,7 @@ struct RequestSyncPacket {
|
|||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
|
var types: SyncTypeFlags? = nil
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -52,12 +64,16 @@ struct RequestSyncPacket {
|
|||||||
case 0x03:
|
case 0x03:
|
||||||
if v.count > maxAcceptBytes { return nil }
|
if v.count > maxAcceptBytes { return nil }
|
||||||
payload = v
|
payload = v
|
||||||
|
case 0x04:
|
||||||
|
if let decoded = SyncTypeFlags.decode(v) {
|
||||||
|
types = decoded
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break // forward compatible; ignore unknown TLVs
|
break // forward compatible; ignore unknown TLVs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
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.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -12,19 +17,32 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
private let cacheFileName = "georelays_cache.csv"
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||||
|
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
private var retryTask: Task<Void, Never>?
|
||||||
|
private var retryAttempt: Int = 0
|
||||||
|
private var isFetching: Bool = false
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Load cached or bundled data synchronously
|
entries = loadLocalEntries()
|
||||||
self.entries = self.loadLocalEntries()
|
registerObservers()
|
||||||
// Fire-and-forget remote refresh if stale
|
startRefreshTimer()
|
||||||
prefetchIfNeeded()
|
prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
retryTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
let center = Geohash.decodeCenter(geohash)
|
let center = Geohash.decodeCenter(geohash)
|
||||||
@@ -62,42 +80,119 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded() {
|
func prefetchIfNeeded(force: Bool = false) {
|
||||||
|
guard !isFetching else { return }
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
|
||||||
|
if !force {
|
||||||
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
|
} else if last != .distantPast,
|
||||||
|
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||||
|
// Skip forced fetches if we just refreshed moments ago.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRetry()
|
||||||
fetchRemote()
|
fetchRemote()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRemote() {
|
private func fetchRemote() {
|
||||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
guard !isFetching else { return }
|
||||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
isFetching = true
|
||||||
Task.detached {
|
|
||||||
|
let request = URLRequest(
|
||||||
|
url: remoteURL,
|
||||||
|
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||||
|
timeoutInterval: 15
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
if !ready {
|
if !ready {
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
await self.handleFetchFailure(.torNotReady)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
|
||||||
guard let self = self else { return }
|
do {
|
||||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
if !parsed.isEmpty {
|
await self.handleFetchFailure(.invalidData)
|
||||||
Task { @MainActor in
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
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) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = cacheURL() else { return }
|
guard let url = cacheURL() else { return }
|
||||||
do {
|
do {
|
||||||
@@ -110,30 +205,35 @@ final class GeoRelayDirectory {
|
|||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = self.cacheURL(),
|
if let cache = cacheURL(),
|
||||||
let data = try? Data(contentsOf: cache),
|
let data = try? Data(contentsOf: cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = [
|
let bundleCandidates = [
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
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"),
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
].compactMap { $0 }
|
].compactMap { $0 }
|
||||||
|
|
||||||
for url in bundleCandidates {
|
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)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -141,7 +241,6 @@ final class GeoRelayDirectory {
|
|||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
// Skip header if present
|
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if line.isEmpty { continue }
|
if line.isEmpty { continue }
|
||||||
@@ -162,11 +261,76 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
private func cacheURL() -> URL? {
|
private func cacheURL() -> URL? {
|
||||||
do {
|
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)
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir.appendingPathComponent(cacheFileName)
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
} catch { return nil }
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
|
|
||||||
struct NostrEmbeddedBitChat {
|
struct NostrEmbeddedBitChat {
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||||
// TLV-encode the private message
|
// TLV-encode the private message
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
|||||||
payload.append(tlv)
|
payload.append(tlv)
|
||||||
|
|
||||||
// Determine 8-byte recipient ID to embed
|
// Determine 8-byte recipient ID to embed
|
||||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientIDHex),
|
recipientID: Data(hexString: recipientID.id),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
payload.append(Data(messageID.utf8))
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientIDHex),
|
recipientID: Data(hexString: recipientID.id),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
|||||||
return "bitchat1:" + base64URLEncode(data)
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerID(publicKey: maybeData).id
|
return PeerID(publicKey: maybeData)
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
|
|
||||||
// Bluetooth state updates for user notifications
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -195,7 +195,7 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
|
|||||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy support - setting this will add to the handlers array
|
// Legacy support - setting this will add to the handlers array
|
||||||
var onPeerAuthenticated: ((String, String) -> Void)? {
|
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
|
||||||
get { nil } // Always return nil for backward compatibility
|
get { nil } // Always return nil for backward compatibility
|
||||||
set {
|
set {
|
||||||
if let handler = newValue {
|
if let handler = newValue {
|
||||||
@@ -546,7 +546,7 @@ final class NoiseEncryptionService {
|
|||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID.id, fingerprint)
|
handler(peerID, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ final class NostrTransport: Transport {
|
|||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { return }
|
guard hrp == "npub" else { return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { return }
|
} catch { return }
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { return }
|
guard hrp == "npub" else { return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { return }
|
} catch { return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ extension NostrTransport {
|
|||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
@@ -171,7 +171,7 @@ extension NostrTransport {
|
|||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
@@ -184,7 +184,7 @@ extension NostrTransport {
|
|||||||
guard !recipientHex.isEmpty else { return }
|
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)
|
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
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ extension NostrTransport {
|
|||||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { scheduleNextReadAck(); return }
|
} catch { scheduleNextReadAck(); return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
scheduleNextReadAck(); return
|
scheduleNextReadAck(); return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,28 +29,30 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
func sendLocalNotification(
|
||||||
// For now, skip app state check entirely to avoid thread issues
|
title: String,
|
||||||
// The NotificationDelegate will handle foreground presentation
|
body: String,
|
||||||
DispatchQueue.main.async {
|
identifier: String,
|
||||||
let content = UNMutableNotificationContent()
|
userInfo: [String: Any]? = nil,
|
||||||
content.title = title
|
interruptionLevel: UNNotificationInterruptionLevel = .active
|
||||||
content.body = body
|
) {
|
||||||
content.sound = .default
|
let content = UNMutableNotificationContent()
|
||||||
if let userInfo = userInfo {
|
content.title = title
|
||||||
content.userInfo = userInfo
|
content.body = body
|
||||||
}
|
content.sound = .default
|
||||||
|
content.interruptionLevel = interruptionLevel
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: identifier,
|
if let userInfo = userInfo {
|
||||||
content: content,
|
content.userInfo = userInfo
|
||||||
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) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -61,11 +63,11 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
||||||
let title = "🔒 DM from \(sender)"
|
let title = "🔒 DM from \(sender)"
|
||||||
let body = message
|
let body = message
|
||||||
let identifier = "private-\(UUID().uuidString)"
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
let userInfo = ["peerID": peerID, "senderName": sender]
|
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
||||||
|
|
||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
@@ -83,25 +85,12 @@ final class NotificationService {
|
|||||||
let title = "👥 bitchatters nearby!"
|
let title = "👥 bitchatters nearby!"
|
||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||||
|
|
||||||
// For network notifications, we want to show them even in foreground
|
sendLocalNotification(
|
||||||
// No app state check - let the notification delegate handle presentation
|
title: title,
|
||||||
DispatchQueue.main.async {
|
body: body,
|
||||||
let content = UNMutableNotificationContent()
|
identifier: identifier,
|
||||||
content.title = title
|
interruptionLevel: .timeSensitive
|
||||||
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
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID.id ?? "",
|
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct RelayController {
|
|||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
|
isFragment: Bool,
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
@@ -36,6 +37,16 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
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
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Announces get a bit more headroom
|
// - Announces get a bit more headroom
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ protocol Transport: AnyObject {
|
|||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
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 sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
@@ -65,6 +66,10 @@ extension Transport {
|
|||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
|
sendMessage(content, mentions: mentions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ enum TransportConfig {
|
|||||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
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
|
// UI / Storage Caps
|
||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
@@ -66,6 +70,7 @@ enum TransportConfig {
|
|||||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||||
|
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||||
@@ -145,6 +150,9 @@ enum TransportConfig {
|
|||||||
|
|
||||||
// Geo relay directory
|
// Geo relay directory
|
||||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
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
|
// BLE operational delays
|
||||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||||
|
|||||||
@@ -8,6 +8,55 @@ final class GossipSyncManager {
|
|||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
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 {
|
struct Config {
|
||||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
@@ -16,25 +65,43 @@ final class GossipSyncManager {
|
|||||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
var stalePeerTimeoutSeconds: 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 myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
private var messages = PacketStore()
|
||||||
private var messageOrder: [String] = []
|
private var fragments = PacketStore()
|
||||||
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
private var fileTransfers = PacketStore()
|
||||||
|
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
private var lastStalePeerCleanup: Date = .distantPast
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
|
private var syncSchedules: [SyncSchedule] = []
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
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() {
|
func start() {
|
||||||
@@ -55,7 +122,18 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
self?.sendRequestSync(to: peerID)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,47 +165,45 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
guard let messageType = MessageType(rawValue: packet.type) else { return }
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
guard let r = packet.recipientID else { return true }
|
guard let r = packet.recipientID else { return true }
|
||||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
}()
|
}()
|
||||||
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
|
||||||
let isAnnounce = (mt == .announce)
|
|
||||||
guard isBroadcastMessage || isAnnounce else { return }
|
|
||||||
|
|
||||||
// Reject expired packets to prevent ghost peers and old messages
|
switch messageType {
|
||||||
guard isPacketFresh(packet) else { return }
|
case .announce:
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
if isAnnounce {
|
|
||||||
guard isAnnouncementFresh(packet) else {
|
guard isAnnouncementFresh(packet) else {
|
||||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
let sender = PeerID(hexData: packet.senderID)
|
||||||
removeState(forNormalizedPeerID: sender)
|
removeState(for: sender)
|
||||||
return
|
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)
|
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() {
|
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload(for: types)
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
@@ -141,8 +217,8 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: PeerID) {
|
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload(for: types)
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID.id
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
@@ -170,6 +246,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
|
let requestedTypes = (request.types ?? .publicMessages)
|
||||||
// Decode GCS into sorted set and prepare membership checker
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -177,60 +254,100 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
if requestedTypes.contains(.announce) {
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
guard isPacketFresh(pkt) else { continue }
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Broadcast messages: send all missing (and not expired)
|
if requestedTypes.contains(.message) {
|
||||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
guard isPacketFresh(pkt) else { continue }
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
if !mightContain(idBytes) {
|
||||||
if !mightContain(idBytes) {
|
var toSend = pkt
|
||||||
var toSend = pkt
|
toSend.ttl = 0
|
||||||
toSend.ttl = 0
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload() -> Data {
|
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
||||||
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
if types.contains(.announce) {
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
||||||
if isPacketFresh(pair.packet) {
|
|
||||||
candidates.append(pair.packet)
|
candidates.append(pair.packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for id in messageOrder {
|
if types.contains(.message) {
|
||||||
if let p = messages[id], isPacketFresh(p) {
|
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
|
||||||
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
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||||
let cap = max(1, config.seenCapacity)
|
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 takeN = min(candidates.count, min(nMax, cap))
|
let takeN = min(candidates.count, min(nMax, cap))
|
||||||
if takeN <= 0 {
|
if takeN <= 0 {
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,20 +358,21 @@ final class GossipSyncManager {
|
|||||||
isPacketFresh(pair.packet)
|
isPacketFresh(pair.packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove expired messages
|
messages.removeExpired(isFresh: isPacketFresh)
|
||||||
let expiredMessageIds = messages.compactMap { id, pkt in
|
fragments.removeExpired(isFresh: isPacketFresh)
|
||||||
isPacketFresh(pkt) ? nil : id
|
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||||
}
|
|
||||||
for id in expiredMessageIds {
|
|
||||||
messages.removeValue(forKey: id)
|
|
||||||
messageOrder.removeAll { $0 == id }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
cleanupExpiredMessages()
|
cleanupExpiredMessages()
|
||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
sendRequestSync()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
@@ -270,40 +388,27 @@ final class GossipSyncManager {
|
|||||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
guard nowMs >= timeoutMs else { return }
|
guard nowMs >= timeoutMs else { return }
|
||||||
let cutoff = nowMs - timeoutMs
|
let cutoff = nowMs - timeoutMs
|
||||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
||||||
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
pair.packet.timestamp < cutoff ? peerID : nil
|
||||||
}
|
}
|
||||||
guard !stalePeerIDs.isEmpty else { return }
|
guard !stalePeerIDs.isEmpty else { return }
|
||||||
for peerKey in stalePeerIDs {
|
for peerKey in stalePeerIDs {
|
||||||
removeState(forNormalizedPeerID: peerKey)
|
removeState(for: peerKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
self?._removeAnnouncementForPeer(peerID)
|
self?.removeState(for: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
private func removeState(for peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.id.lowercased()
|
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||||
removeState(forNormalizedPeerID: normalizedPeerID)
|
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
}
|
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
|
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,13 +422,13 @@ extension GossipSyncManager {
|
|||||||
|
|
||||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
latestAnnouncementByPeer[peerID] != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func _messageCount(for peerID: PeerID) -> Int {
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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,15 +61,13 @@ struct CompressionUtil {
|
|||||||
// 1. Data is too small
|
// 1. Data is too small
|
||||||
// 2. Data appears to be already compressed (high entropy)
|
// 2. Data appears to be already compressed (high entropy)
|
||||||
guard data.count >= compressionThreshold else { return false }
|
guard data.count >= compressionThreshold else { return false }
|
||||||
|
|
||||||
// Simple entropy check - count unique bytes
|
// Quick uniqueness check — a high diversity of bytes usually means the
|
||||||
var byteFrequency = [UInt8: Int]()
|
// payload is already compressed. We only need to know how many unique
|
||||||
for byte in data {
|
// values exist rather than keeping full frequency counts.
|
||||||
byteFrequency[byte, default: 0] += 1
|
let uniqueByteCount = Set(data).count
|
||||||
}
|
let sampleSize = min(data.count, 256)
|
||||||
|
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
|
||||||
// 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
|
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).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Voice notes stay small for low-latency relays.
|
/// Voice notes stay small for low-latency relays.
|
||||||
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
static let maxFramedFileBytes: Int = {
|
static let maxFramedFileBytes: Int = {
|
||||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
/// Comprehensive input validation for BitChat protocol
|
/// Comprehensive input validation for BitChat protocol
|
||||||
/// Prevents injection attacks, buffer overflows, and malformed data
|
/// Prevents injection attacks, buffer overflows, and malformed data
|
||||||
@@ -16,29 +17,28 @@ struct InputValidator {
|
|||||||
// MARK: - String Content Validation
|
// MARK: - String Content Validation
|
||||||
|
|
||||||
/// Validates and sanitizes user-provided strings used in UI
|
/// 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? {
|
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||||
// Check empty
|
|
||||||
guard !string.isEmpty else { return nil }
|
|
||||||
|
|
||||||
// Trim whitespace
|
|
||||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
// Check length
|
|
||||||
guard trimmed.count <= maxLength else { return nil }
|
guard trimmed.count <= maxLength else { return nil }
|
||||||
|
|
||||||
// Remove control characters
|
// Reject control characters outright instead of rewriting the string.
|
||||||
|
// This prevents injection attacks and ensures consistent UI rendering.
|
||||||
let controlChars = CharacterSet.controlCharacters
|
let controlChars = CharacterSet.controlCharacters
|
||||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
|
||||||
|
// Log rejection for monitoring, without exposing actual content for privacy
|
||||||
// Ensure valid UTF-8 (should already be, but double-check)
|
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
|
||||||
guard cleaned.data(using: .utf8) != nil else { return nil }
|
SecureLogger.debug(
|
||||||
|
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
|
||||||
// Prevent zero-width characters and other invisible unicode
|
category: .security
|
||||||
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
|
)
|
||||||
let visible = cleaned.components(separatedBy: invisibleChars).joined()
|
return nil
|
||||||
|
}
|
||||||
return visible.isEmpty ? nil : visible
|
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates nickname
|
/// Validates nickname
|
||||||
|
|||||||
@@ -145,6 +145,19 @@ 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 rateBucketsBySender: [String: TokenBucket] = [:]
|
||||||
private var rateBucketsByContent: [String: TokenBucket] = [:]
|
private var rateBucketsByContent: [String: TokenBucket] = [:]
|
||||||
private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity
|
private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity
|
||||||
@@ -158,7 +171,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if spid.isGeoChat || spid.isGeoDM {
|
if spid.isGeoChat || spid.isGeoDM {
|
||||||
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
|
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
|
||||||
return "nostr:" + full
|
return "nostr:" + full
|
||||||
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() {
|
||||||
return "noise:" + full
|
return "noise:" + full
|
||||||
} else {
|
} else {
|
||||||
return "mesh:" + spid.id.lowercased()
|
return "mesh:" + spid.id.lowercased()
|
||||||
@@ -235,10 +248,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@Published var currentColorScheme: ColorScheme = .light
|
@Published var currentColorScheme: ColorScheme = .light
|
||||||
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
private var hasNotifiedNetworkAvailable = false
|
|
||||||
private var recentlySeenPeers: Set<PeerID> = []
|
private var recentlySeenPeers: Set<PeerID> = []
|
||||||
private var lastNetworkNotificationTime = Date.distantPast
|
private var lastNetworkNotificationTime = Date.distantPast
|
||||||
private var networkResetTimer: Timer? = nil
|
private var networkResetTimer: Timer? = nil
|
||||||
|
private var networkEmptyTimer: Timer? = nil
|
||||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -248,7 +261,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname = trimmed
|
nickname = trimmed
|
||||||
}
|
}
|
||||||
// Update mesh service nickname if it's initialized
|
// Update mesh service nickname if it's initialized
|
||||||
if meshService.myPeerID != "" {
|
if !meshService.myPeerID.isEmpty {
|
||||||
meshService.setNickname(nickname)
|
meshService.setNickname(nickname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,16 +328,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]
|
private var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]
|
||||||
private var selectedPrivateChatFingerprint: String? = nil
|
private var selectedPrivateChatFingerprint: String? = nil
|
||||||
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
||||||
private var shortIDToNoiseKey: [PeerID: String] = [:]
|
private var shortIDToNoiseKey: [PeerID: PeerID] = [:]
|
||||||
|
|
||||||
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
|
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
|
||||||
@MainActor
|
@MainActor
|
||||||
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> String? {
|
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? {
|
||||||
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
|
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
|
||||||
// Fallback: derive from active Noise session if available
|
// Fallback: derive from active Noise session if available
|
||||||
if shortPeerID.id.count == 16,
|
if shortPeerID.id.count == 16,
|
||||||
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
||||||
let stable = key.hexEncodedString()
|
let stable = PeerID(hexData: key)
|
||||||
shortIDToNoiseKey[shortPeerID] = stable
|
shortIDToNoiseKey[shortPeerID] = stable
|
||||||
return stable
|
return stable
|
||||||
}
|
}
|
||||||
@@ -333,16 +346,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)
|
// Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)
|
||||||
@MainActor
|
@MainActor
|
||||||
func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> PeerID? {
|
func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID {
|
||||||
|
guard fullNoiseKeyHex.id.count == 64 else { return fullNoiseKeyHex }
|
||||||
// Check known peers for a noise key match
|
// Check known peers for a noise key match
|
||||||
if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) {
|
if let match = allPeers.first(where: { PeerID(hexData: $0.noisePublicKey) == fullNoiseKeyHex }) {
|
||||||
return match.peerID
|
return match.peerID
|
||||||
}
|
}
|
||||||
// Also search cache mapping
|
// Also search cache mapping
|
||||||
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
|
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
|
||||||
return pair.key
|
return pair.key
|
||||||
}
|
}
|
||||||
return nil
|
return fullNoiseKeyHex
|
||||||
}
|
}
|
||||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
|
|
||||||
@@ -1141,7 +1155,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderName,
|
from: senderName,
|
||||||
message: pm.content,
|
message: pm.content,
|
||||||
peerID: convKey.id
|
peerID: convKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1443,20 +1457,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Parse mentions from the content (use original content for user intent)
|
// Parse mentions from the content (use original content for user intent)
|
||||||
let mentions = parseMentions(from: content)
|
let mentions = parseMentions(from: content)
|
||||||
|
|
||||||
|
var geoContext: GeoOutgoingContext? = nil
|
||||||
|
|
||||||
// Add message to local display
|
// Add message to local display
|
||||||
var displaySender = nickname
|
var displaySender = nickname
|
||||||
var localSenderPeerID = meshService.myPeerID
|
var localSenderPeerID = meshService.myPeerID
|
||||||
if case .location(let ch) = activeChannel,
|
var messageID: String? = nil
|
||||||
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
var messageTimestamp = Date()
|
||||||
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
|
|
||||||
displaySender = nickname + "#" + suffix
|
switch activeChannel {
|
||||||
localSenderPeerID = PeerID(nostr: myGeoIdentity.publicKeyHex)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
sender: displaySender,
|
sender: displaySender,
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
timestamp: Date(),
|
timestamp: messageTimestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
senderPeerID: localSenderPeerID,
|
senderPeerID: localSenderPeerID,
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
@@ -1487,69 +1529,75 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// UI updates automatically via @Published var messages
|
// UI updates automatically via @Published var messages
|
||||||
|
|
||||||
updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions)
|
updateChannelActivityTimeThenSend(content: content,
|
||||||
|
trimmed: trimmed,
|
||||||
|
mentions: mentions,
|
||||||
|
geoContext: geoContext,
|
||||||
|
messageID: message.id,
|
||||||
|
timestamp: message.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String]) {
|
private func updateChannelActivityTimeThenSend(content: String,
|
||||||
|
trimmed: String,
|
||||||
|
mentions: [String],
|
||||||
|
geoContext: GeoOutgoingContext?,
|
||||||
|
messageID: String,
|
||||||
|
timestamp: Date) {
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
lastPublicActivityAt["mesh"] = Date()
|
lastPublicActivityAt["mesh"] = Date()
|
||||||
// Send via mesh with mentions
|
// Send via mesh with mentions
|
||||||
meshService.sendMessage(content, mentions: mentions)
|
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
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
|
// Send to geohash channel via Nostr ephemeral
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
sendGeohash(ch: ch, content: trimmed)
|
self.sendGeohash(context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func sendGeohash(ch: GeohashChannel, content: String) {
|
private func sendGeohash(context: GeoOutgoingContext) {
|
||||||
do {
|
let ch = context.channel
|
||||||
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
let event = context.event
|
||||||
|
let identity = context.identity
|
||||||
|
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||||
content: content,
|
toGeohash: ch.geohash,
|
||||||
geohash: ch.geohash,
|
count: TransportConfig.nostrGeoRelayCount
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track ourselves as active participant
|
if targetRelays.isEmpty {
|
||||||
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
||||||
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
} else {
|
||||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session)
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
|
||||||
// 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
|
@MainActor
|
||||||
@@ -1838,7 +1886,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderName,
|
from: senderName,
|
||||||
message: pm.content,
|
message: pm.content,
|
||||||
peerID: convKey.id
|
peerID: convKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1918,8 +1966,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||||
guard let peerID else { return false }
|
guard let peerID else { return false }
|
||||||
if peerID == meshService.myPeerID { return true }
|
if peerID == meshService.myPeerID { return true }
|
||||||
let lowerPeer = peerID.id.lowercased()
|
guard peerID.isGeoDM || peerID.isGeoChat else { return false }
|
||||||
guard lowerPeer.hasPrefix("nostr") else { return false }
|
|
||||||
|
|
||||||
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
|
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
|
||||||
let gh = currentGeohash,
|
let gh = currentGeohash,
|
||||||
@@ -1929,10 +1976,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
if let gh = currentGeohash,
|
if let gh = currentGeohash,
|
||||||
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
|
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||||||
let myLower = myIdentity.publicKeyHex.lowercased()
|
if peerID == PeerID(nostr: myIdentity.publicKeyHex) { return true }
|
||||||
let shortLen = TransportConfig.nostrShortKeyDisplayLength
|
|
||||||
let shortKey = "nostr:" + myLower.prefix(shortLen)
|
|
||||||
if lowerPeer == shortKey { return true }
|
|
||||||
let suffix = myIdentity.publicKeyHex.suffix(4)
|
let suffix = myIdentity.publicKeyHex.suffix(4)
|
||||||
let expected = (nickname + "#" + suffix).lowercased()
|
let expected = (nickname + "#" + suffix).lowercased()
|
||||||
if let display = displayName?.lowercased(), display == expected { return true }
|
if let display = displayName?.lowercased(), display == expected { return true }
|
||||||
@@ -2407,8 +2451,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendVoiceNote(at url: URL) {
|
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 targetPeer = selectedPrivateChatPeer
|
||||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer?.id)
|
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
|
|
||||||
@@ -2455,6 +2506,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
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
|
let targetPeer = selectedPrivateChatPeer
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
@@ -2480,7 +2538,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
)
|
)
|
||||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer?.id)
|
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = self.makeTransferID(messageID: messageID)
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
@@ -2522,7 +2580,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage {
|
private func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
||||||
let timestamp = Date()
|
let timestamp = Date()
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
|
|
||||||
@@ -2539,7 +2597,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
var chats = privateChats
|
var chats = privateChats
|
||||||
chats[PeerID(str: peerID), default: []].append(message)
|
chats[peerID, default: []].append(message)
|
||||||
privateChats = chats
|
privateChats = chats
|
||||||
trimMessagesIfNeeded()
|
trimMessagesIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
@@ -2552,7 +2610,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: PeerID(str: senderPeerID),
|
senderPeerID: senderPeerID,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
@@ -2577,29 +2635,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentPublicSender() -> (name: String, peerID: String) {
|
private func currentPublicSender() -> (name: String, peerID: PeerID) {
|
||||||
var displaySender = nickname
|
var displaySender = nickname
|
||||||
var senderPeerID = meshService.myPeerID
|
var senderPeerID = meshService.myPeerID
|
||||||
if case .location(let ch) = activeChannel,
|
if case .location(let ch) = activeChannel,
|
||||||
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||||
displaySender = nickname + "#" + suffix
|
displaySender = nickname + "#" + suffix
|
||||||
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
|
senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||||
senderPeerID = PeerID(str: "nostr:\(shortKey)")
|
|
||||||
}
|
}
|
||||||
return (displaySender, senderPeerID.id)
|
return (displaySender, senderPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func nicknameForPeer(_ peerID: String) -> String {
|
private func nicknameForPeer(_ peerID: PeerID) -> String {
|
||||||
if let name = meshService.peerNickname(peerID: PeerID(str: peerID)) {
|
if let name = meshService.peerNickname(peerID: peerID) {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: PeerID(str: peerID)),
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
!favorite.peerNickname.isEmpty {
|
!favorite.peerNickname.isEmpty {
|
||||||
return favorite.peerNickname
|
return favorite.peerNickname
|
||||||
}
|
}
|
||||||
if let noiseKey = Data(hexString: peerID),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
!favorite.peerNickname.isEmpty {
|
!favorite.peerNickname.isEmpty {
|
||||||
return favorite.peerNickname
|
return favorite.peerNickname
|
||||||
@@ -3288,7 +3345,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// In public chat - send to active public channel
|
// In public chat - send to active public channel
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
meshService.sendMessage(screenshotMessage, mentions: [])
|
meshService.sendMessage(screenshotMessage,
|
||||||
|
mentions: [],
|
||||||
|
messageID: UUID().uuidString,
|
||||||
|
timestamp: Date())
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
@@ -3442,7 +3502,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if !sentReadReceipts.contains(message.id) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
// Use stable Noise key hex if available; else fall back to peerID
|
// Use stable Noise key hex if available; else fall back to peerID
|
||||||
let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
|
let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
|
||||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname)
|
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||||
messageRouter.sendReadReceipt(receipt, to: recipPeer)
|
messageRouter.sendReadReceipt(receipt, to: recipPeer)
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
}
|
}
|
||||||
@@ -4056,7 +4116,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if let spid = message.senderPeerID {
|
if let spid = message.senderPeerID {
|
||||||
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
|
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
|
||||||
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return spid == meshService.myPeerID
|
return spid == meshService.myPeerID
|
||||||
@@ -4209,13 +4269,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let noiseService = meshService.getNoiseService()
|
let noiseService = meshService.getNoiseService()
|
||||||
|
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
if noiseService.hasEstablishedSession(with: peerID) {
|
||||||
// Check if fingerprint is verified using our persisted data
|
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
|
||||||
peerEncryptionStatus[peerID] = .noiseVerified
|
|
||||||
} else {
|
|
||||||
peerEncryptionStatus[peerID] = .noiseSecured
|
|
||||||
}
|
|
||||||
} else if noiseService.hasSession(with: peerID) {
|
} else if noiseService.hasSession(with: peerID) {
|
||||||
// Session exists but not established - handshaking
|
// Session exists but not established - handshaking
|
||||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||||||
@@ -4250,27 +4304,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Determine status based on session state
|
// Determine status based on session state
|
||||||
switch sessionState {
|
switch sessionState {
|
||||||
case .established:
|
case .established:
|
||||||
// We have encryption, now check if it's verified
|
status = encryptionStatus(for: peerID)
|
||||||
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:
|
case .handshaking, .handshakeQueued:
|
||||||
// If we've ever established a session, show secured instead of handshaking
|
// If we've ever established a session, show secured instead of handshaking
|
||||||
if hasEverEstablishedSession {
|
if hasEverEstablishedSession {
|
||||||
// Check if it was verified before
|
// Check if it was verified before
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
status = encryptionStatus(for: peerID)
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
|
||||||
status = .noiseVerified
|
|
||||||
} else {
|
|
||||||
status = .noiseSecured
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// First time establishing - show handshaking
|
// First time establishing - show handshaking
|
||||||
status = .noiseHandshaking
|
status = .noiseHandshaking
|
||||||
@@ -4279,12 +4318,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// If we've ever established a session, show secured instead of no handshake
|
// If we've ever established a session, show secured instead of no handshake
|
||||||
if hasEverEstablishedSession {
|
if hasEverEstablishedSession {
|
||||||
// Check if it was verified before
|
// Check if it was verified before
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
status = encryptionStatus(for: peerID)
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
|
||||||
status = .noiseVerified
|
|
||||||
} else {
|
|
||||||
status = .noiseSecured
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Never established - show no handshake
|
// Never established - show no handshake
|
||||||
status = .noHandshake
|
status = .noHandshake
|
||||||
@@ -4293,12 +4327,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// If we've ever established a session, show secured instead of failed
|
// If we've ever established a session, show secured instead of failed
|
||||||
if hasEverEstablishedSession {
|
if hasEverEstablishedSession {
|
||||||
// Check if it was verified before
|
// Check if it was verified before
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
status = encryptionStatus(for: peerID)
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
|
||||||
status = .noiseVerified
|
|
||||||
} else {
|
|
||||||
status = .noiseSecured
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Never established - show failed
|
// Never established - show failed
|
||||||
status = .none
|
status = .none
|
||||||
@@ -4372,7 +4401,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func meshSeed(for peerID: PeerID) -> String {
|
private func meshSeed(for peerID: PeerID) -> String {
|
||||||
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() {
|
||||||
return "noise:" + full
|
return "noise:" + full
|
||||||
}
|
}
|
||||||
return peerID.id.lowercased()
|
return peerID.id.lowercased()
|
||||||
@@ -4689,16 +4718,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let noiseService = meshService.getNoiseService()
|
let noiseService = meshService.getNoiseService()
|
||||||
|
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
if noiseService.hasEstablishedSession(with: peerID) {
|
||||||
if let fingerprint = getFingerprint(for: peerID) {
|
peerEncryptionStatus[peerID] = encryptionStatus(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) {
|
} else if noiseService.hasSession(with: peerID) {
|
||||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||||||
} else {
|
} else {
|
||||||
@@ -4728,6 +4748,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return unifiedPeerService.getFingerprint(for: peerID)
|
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
|
/// Helper to resolve nickname for a peer ID through various sources
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resolveNickname(for peerID: PeerID) -> String {
|
private func resolveNickname(for peerID: PeerID) -> String {
|
||||||
@@ -4829,7 +4859,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let peerID = PeerID(str: peerID)
|
|
||||||
|
|
||||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||||
|
|
||||||
@@ -4848,9 +4877,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Cache shortID -> full Noise key mapping as soon as session authenticates
|
// Cache shortID -> full Noise key mapping as soon as session authenticates
|
||||||
if self.shortIDToNoiseKey[peerID] == nil,
|
if self.shortIDToNoiseKey[peerID] == nil,
|
||||||
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||||||
let stable = keyData.hexEncodedString()
|
let stable = PeerID(hexData: keyData)
|
||||||
self.shortIDToNoiseKey[peerID] = stable
|
self.shortIDToNoiseKey[peerID] = stable
|
||||||
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))…", category: .session)
|
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
||||||
@@ -5034,12 +5063,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let publicMentions = parseMentions(from: normalized)
|
let publicMentions = parseMentions(from: normalized)
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
id: UUID().uuidString,
|
id: messageID,
|
||||||
sender: nickname,
|
sender: nickname,
|
||||||
content: normalized,
|
content: normalized,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
@@ -5115,7 +5144,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Cache mapping to full Noise key for session continuity on disconnect
|
// Cache mapping to full Noise key for session continuity on disconnect
|
||||||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||||||
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
shortIDToNoiseKey[peerID] = noiseKeyHex
|
shortIDToNoiseKey[peerID] = noiseKeyHex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5131,15 +5160,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
identityManager.removeEphemeralSession(peerID: peerID)
|
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)
|
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
|
||||||
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
|
var derivedStableKeyHex = shortIDToNoiseKey[peerID]
|
||||||
if derivedStableKeyHex == nil,
|
if derivedStableKeyHex == nil,
|
||||||
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||||||
derivedStableKeyHex = key.hexEncodedString()
|
derivedStableKeyHex = PeerID(hexData: key)
|
||||||
shortIDToNoiseKey[peerID] = derivedStableKeyHex
|
shortIDToNoiseKey[peerID] = derivedStableKeyHex
|
||||||
}
|
}
|
||||||
|
|
||||||
if let current = selectedPrivateChatPeer, current == peerID,
|
if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex {
|
||||||
let stableKeyHex = PeerID(str: derivedStableKeyHex) {
|
|
||||||
// Migrate messages view context to stable key so header shows favorite + Nostr globe
|
// Migrate messages view context to stable key so header shows favorite + Nostr globe
|
||||||
if let messages = privateChats[peerID] {
|
if let messages = privateChats[peerID] {
|
||||||
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
|
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
|
||||||
@@ -5201,34 +5229,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.cleanupStaleUnreadPeerIDs()
|
self.cleanupStaleUnreadPeerIDs()
|
||||||
|
|
||||||
// Smart notification logic for "bitchatters nearby"
|
// Smart notification logic for "bitchatters nearby"
|
||||||
if !peers.isEmpty {
|
let meshPeers = peers.filter { peerID in
|
||||||
// Cancel any pending reset if peers are back
|
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||||
self.networkResetTimer?.invalidate()
|
}
|
||||||
self.networkResetTimer = nil
|
let meshPeerSet = Set(meshPeers)
|
||||||
// Count mesh peers that are connected OR recently reachable via mesh relays
|
|
||||||
let meshPeers = peers.filter { peerID in
|
if meshPeerSet.isEmpty {
|
||||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
self.scheduleNetworkEmptyTimer()
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
} else {
|
||||||
// No peers — immediately reset to allow next rising-edge to notify
|
self.invalidateNetworkEmptyTimer()
|
||||||
self.hasNotifiedNetworkAvailable = false
|
// Trim out peers we no longer observe before comparing for new arrivals
|
||||||
self.recentlySeenPeers.removeAll()
|
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||||||
if self.networkResetTimer != nil {
|
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
|
||||||
self.networkResetTimer?.invalidate()
|
|
||||||
self.networkResetTimer = nil
|
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()
|
||||||
}
|
}
|
||||||
SecureLogger.debug("⏳ Mesh empty — reset network notification state", category: .session)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register ephemeral sessions for all connected peers
|
// Register ephemeral sessions for all connected peers
|
||||||
@@ -5298,6 +5321,71 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Keep only receipts from messages we still have
|
// Keep only receipts from messages we still have
|
||||||
cleanupOldReadReceipts()
|
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() {
|
private func cleanupOldReadReceipts() {
|
||||||
// Skip cleanup during startup phase or if privateChats is empty
|
// Skip cleanup during startup phase or if privateChats is empty
|
||||||
@@ -5454,6 +5542,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
)
|
)
|
||||||
// Append to current visible messages
|
// Append to current visible messages
|
||||||
messages.append(systemMessage)
|
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
|
// Persist into the backing store for the active channel to survive rebinds
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
@@ -5504,7 +5595,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Default: send over mesh
|
// Default: send over mesh
|
||||||
meshService.sendMessage(content, mentions: [])
|
meshService.sendMessage(content,
|
||||||
|
mentions: [],
|
||||||
|
messageID: UUID().uuidString,
|
||||||
|
timestamp: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||||
@@ -5578,7 +5672,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate recipient
|
// Validate recipient
|
||||||
if let rid = packet.recipientID, rid.hexEncodedString() != meshService.myPeerID {
|
if PeerID(hexData: packet.recipientID) != meshService.myPeerID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5788,7 +5882,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
if !sentReadReceipts.contains(message.id) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
if let key {
|
if let key {
|
||||||
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname)
|
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||||
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
||||||
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
@@ -5823,7 +5917,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderNickname,
|
from: senderNickname,
|
||||||
message: messageContent,
|
message: messageContent,
|
||||||
peerID: targetPeerID.id
|
peerID: targetPeerID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6216,7 +6310,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: message.sender,
|
from: message.sender,
|
||||||
message: message.content,
|
message: message.content,
|
||||||
peerID: peerID.id
|
peerID: peerID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -6231,7 +6325,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if !sentReadReceipts.contains(message.id) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService.myPeerID.id,
|
readerID: meshService.myPeerID,
|
||||||
readerNickname: nickname
|
readerNickname: nickname
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -6270,7 +6364,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
||||||
|
|
||||||
// Apply per-sender and per-content rate limits (drop if exceeded)
|
// Apply per-sender and per-content rate limits (drop if exceeded)
|
||||||
if finalMessage.sender != "system" {
|
// 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 {
|
||||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||||
let contentKey = normalizedContentKey(finalMessage.content)
|
let contentKey = normalizedContentKey(finalMessage.content)
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -6288,8 +6384,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Persist mesh messages to mesh timeline always
|
// Persist mesh messages to mesh timeline always
|
||||||
if !isGeo && finalMessage.sender != "system" {
|
if !isGeo && finalMessage.sender != "system" {
|
||||||
meshTimeline.append(finalMessage)
|
if !meshTimeline.contains(where: { $0.id == finalMessage.id }) {
|
||||||
trimMeshTimelineIfNeeded()
|
meshTimeline.append(finalMessage)
|
||||||
|
trimMeshTimelineIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist geochat messages to per-geohash timeline
|
// Persist geochat messages to per-geohash timeline
|
||||||
|
|||||||
+136
-72
@@ -47,7 +47,7 @@ struct ContentView: View {
|
|||||||
@State private var commandSuggestions: [String] = []
|
@State private var commandSuggestions: [String] = []
|
||||||
@State private var showMessageActions = false
|
@State private var showMessageActions = false
|
||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: String?
|
@State private var selectedMessageSenderID: PeerID?
|
||||||
@FocusState private var isNicknameFieldFocused: Bool
|
@FocusState private var isNicknameFieldFocused: Bool
|
||||||
@State private var isAtBottomPublic: Bool = true
|
@State private var isAtBottomPublic: Bool = true
|
||||||
@State private var isAtBottomPrivate: Bool = true
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@@ -80,7 +80,7 @@ struct ContentView: View {
|
|||||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@State private var windowCountPrivate: [String: Int] = [:]
|
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
|
|
||||||
private struct PrivateHeaderContext {
|
private struct PrivateHeaderContext {
|
||||||
let headerPeerID: String
|
let headerPeerID: PeerID
|
||||||
let peer: BitchatPeer?
|
let peer: BitchatPeer?
|
||||||
let displayName: String
|
let displayName: String
|
||||||
let isNostrAvailable: Bool
|
let isNostrAvailable: Bool
|
||||||
@@ -188,10 +188,6 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
peopleSheetView
|
peopleSheetView
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAppInfo) {
|
.sheet(isPresented: $showAppInfo) {
|
||||||
AppInfoView()
|
AppInfoView()
|
||||||
@@ -203,11 +199,19 @@ struct ContentView: View {
|
|||||||
set: { _ in viewModel.showingFingerprintFor = nil }
|
set: { _ in viewModel.showingFingerprintFor = nil }
|
||||||
)) {
|
)) {
|
||||||
if let peerID = viewModel.showingFingerprintFor {
|
if let peerID = viewModel.showingFingerprintFor {
|
||||||
FingerprintView(viewModel: viewModel, peerID: peerID.id)
|
FingerprintView(viewModel: viewModel, peerID: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.sheet(isPresented: $showImagePicker) {
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
showImagePicker = false
|
showImagePicker = false
|
||||||
if let image = image {
|
if let image = image {
|
||||||
@@ -223,13 +227,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.hidden)
|
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.sheet(isPresented: $showMacImagePicker) {
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
MacImagePickerView { url in
|
MacImagePickerView { url in
|
||||||
showMacImagePicker = false
|
showMacImagePicker = false
|
||||||
if let url = url {
|
if let url = url {
|
||||||
@@ -275,12 +285,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("content.actions.direct_message") {
|
Button("content.actions.direct_message") {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
if peerID.hasPrefix("nostr:") {
|
if peerID.isGeoChat {
|
||||||
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) {
|
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
viewModel.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
@@ -302,8 +312,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("content.actions.block", role: .destructive) {
|
Button("content.actions.block", role: .destructive) {
|
||||||
// Prefer direct geohash block when we have a Nostr sender ID
|
// Prefer direct geohash block when we have a Nostr sender ID
|
||||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||||
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)),
|
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||||
let sender = selectedMessageSender {
|
let sender = selectedMessageSender {
|
||||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||||
} else if let sender = selectedMessageSender {
|
} else if let sender = selectedMessageSender {
|
||||||
@@ -334,9 +344,9 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Message List View
|
// MARK: - Message List View
|
||||||
|
|
||||||
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
|
private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {
|
||||||
let messages: [BitchatMessage] = {
|
let messages: [BitchatMessage] = {
|
||||||
if let peerID = PeerID(str: privatePeer) {
|
if let peerID = privatePeer {
|
||||||
return viewModel.getPrivateChatMessages(for: peerID)
|
return viewModel.getPrivateChatMessages(for: peerID)
|
||||||
}
|
}
|
||||||
return viewModel.messages
|
return viewModel.messages
|
||||||
@@ -476,7 +486,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: viewModel.privateChats) { _ in
|
.onChange(of: viewModel.privateChats) { _ in
|
||||||
if let peerID = privatePeer,
|
if let peerID = privatePeer,
|
||||||
let messages = viewModel.privateChats[PeerID(str: peerID)],
|
let messages = viewModel.privateChats[peerID],
|
||||||
!messages.isEmpty {
|
!messages.isEmpty {
|
||||||
// If the newest private message is from me, always scroll
|
// If the newest private message is from me, always scroll
|
||||||
let lastMsg = messages.last!
|
let lastMsg = messages.last!
|
||||||
@@ -531,7 +541,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
if let peerID = PeerID(str: privatePeer) {
|
if let peerID = privatePeer {
|
||||||
// Try multiple times to ensure read receipts are sent
|
// Try multiple times to ensure read receipts are sent
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
|
||||||
@@ -784,7 +794,7 @@ struct ContentView: View {
|
|||||||
case "user":
|
case "user":
|
||||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||||
selectedMessageSenderID = peerID.id
|
selectedMessageSenderID = peerID
|
||||||
|
|
||||||
if peerID.isGeoDM || peerID.isGeoChat {
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||||
@@ -832,10 +842,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToBottom(on proxy: ScrollViewProxy,
|
private func scrollToBottom(on proxy: ScrollViewProxy,
|
||||||
privatePeer: String?,
|
privatePeer: PeerID?,
|
||||||
isAtBottom: Binding<Bool>) {
|
isAtBottom: Binding<Bool>) {
|
||||||
let targetID: String? = {
|
let targetID: String? = {
|
||||||
if let peer = PeerID(str: privatePeer),
|
if let peer = privatePeer,
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
@@ -861,7 +871,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
let secondTarget: String? = {
|
let secondTarget: String? = {
|
||||||
if let peer = PeerID(str: privatePeer),
|
if let peer = privatePeer,
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
@@ -912,6 +922,53 @@ struct ContentView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#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
|
// MARK: - People Sheet Views
|
||||||
@@ -998,14 +1055,14 @@ struct ContentView: View {
|
|||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
secondaryTextColor: secondaryTextColor,
|
secondaryTextColor: secondaryTextColor,
|
||||||
onTapPeer: { peerID in
|
onTapPeer: { peerID in
|
||||||
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
viewModel.startPrivateChat(with: peerID)
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
},
|
},
|
||||||
onToggleFavorite: { peerID in
|
onToggleFavorite: { peerID in
|
||||||
viewModel.toggleFavorite(peerID: PeerID(str: peerID))
|
viewModel.toggleFavorite(peerID: peerID)
|
||||||
},
|
},
|
||||||
onShowFingerprint: { peerID in
|
onShowFingerprint: { peerID in
|
||||||
viewModel.showFingerprint(for: PeerID(str: peerID))
|
viewModel.showFingerprint(for: peerID)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1020,7 +1077,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var privateChatSheetView: some View {
|
private var privateChatSheetView: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let privatePeerID = viewModel.selectedPrivateChatPeer?.id {
|
if let privatePeerID = viewModel.selectedPrivateChatPeer {
|
||||||
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -1044,12 +1101,11 @@ struct ContentView: View {
|
|||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
||||||
let peerID = PeerID(str: headerContext.headerPeerID)
|
let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID)
|
||||||
let isFavorite = viewModel.isFavorite(peerID: peerID)
|
|
||||||
|
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.isGeoDM {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.toggleFavorite(peerID: peerID)
|
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: isFavorite ? "star.fill" : "star")
|
Image(systemName: isFavorite ? "star.fill" : "star")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
@@ -1088,7 +1144,7 @@ struct ContentView: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate)
|
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
Divider()
|
Divider()
|
||||||
@@ -1110,9 +1166,9 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: String) -> some View {
|
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.showFingerprint(for: PeerID(str: context.headerPeerID))
|
viewModel.showFingerprint(for: context.headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
if let connectionState = context.peer?.connectionState {
|
if let connectionState = context.peer?.connectionState {
|
||||||
@@ -1135,7 +1191,7 @@ struct ContentView: View {
|
|||||||
case .offline:
|
case .offline:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
} else if viewModel.meshService.isPeerReachable(PeerID(str: context.headerPeerID)) {
|
} else if viewModel.meshService.isPeerReachable(context.headerPeerID) {
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -1145,7 +1201,7 @@ struct ContentView: View {
|
|||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||||
} else if viewModel.meshService.isPeerConnected(PeerID(str: context.headerPeerID)) || viewModel.connectedPeers.contains(PeerID(str: context.headerPeerID)) {
|
} else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) {
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -1156,14 +1212,9 @@ struct ContentView: View {
|
|||||||
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.isGeoDM {
|
||||||
let statusPeerID: String = {
|
let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
return short.id
|
|
||||||
}
|
|
||||||
return context.headerPeerID
|
|
||||||
}()
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
@@ -1195,33 +1246,27 @@ struct ContentView: View {
|
|||||||
.frame(height: headerHeight)
|
.frame(height: headerHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makePrivateHeaderContext(for privatePeerID: String) -> PrivateHeaderContext {
|
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
|
||||||
let headerPeerID: String = {
|
let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||||
return short.id
|
|
||||||
}
|
|
||||||
return privatePeerID
|
|
||||||
}()
|
|
||||||
|
|
||||||
let peer = viewModel.getPeer(byID: PeerID(str: headerPeerID))
|
|
||||||
|
|
||||||
let displayName: String = {
|
let displayName: String = {
|
||||||
if privatePeerID.hasPrefix("nostr_"), case .location(let ch) = locationManager.selectedChannel {
|
if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel {
|
||||||
let disp = viewModel.geohashDisplayName(for: PeerID(str: privatePeerID))
|
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
||||||
return "#\(ch.geohash)/@\(disp)"
|
return "#\(ch.geohash)/@\(disp)"
|
||||||
}
|
}
|
||||||
if let name = peer?.displayName { return name }
|
if let name = peer?.displayName { return name }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()),
|
||||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
if headerPeerID.count == 16 {
|
if headerPeerID.id.count == 16 {
|
||||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
|
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||||
if let id = candidates.first,
|
if let id = candidates.first,
|
||||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
}
|
}
|
||||||
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
} else if let keyData = headerPeerID.noiseKey {
|
||||||
let fp = keyData.sha256Fingerprint()
|
let fp = keyData.sha256Fingerprint()
|
||||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
@@ -1233,7 +1278,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
let isNostrAvailable: Bool = {
|
let isNostrAvailable: Bool = {
|
||||||
guard let connectionState = peer?.connectionState else {
|
guard let connectionState = peer?.connectionState else {
|
||||||
if let noiseKey = Data(hexString: headerPeerID),
|
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
favoriteStatus.isMutual {
|
favoriteStatus.isMutual {
|
||||||
return true
|
return true
|
||||||
@@ -1752,7 +1797,7 @@ private extension ContentView {
|
|||||||
|
|
||||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
private func expandWindow(ifNeededFor message: BitchatMessage,
|
||||||
allMessages: [BitchatMessage],
|
allMessages: [BitchatMessage],
|
||||||
privatePeer: String?,
|
privatePeer: PeerID?,
|
||||||
proxy: ScrollViewProxy) {
|
proxy: ScrollViewProxy) {
|
||||||
let step = TransportConfig.uiWindowStepCount
|
let step = TransportConfig.uiWindowStepCount
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
@@ -1812,7 +1857,19 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowMediaControls: Bool {
|
private var shouldShowMediaControls: Bool {
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
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) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
@@ -1857,17 +1914,23 @@ private extension ContentView {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
var sendOrMicButton: some View {
|
var sendOrMicButton: some View {
|
||||||
let hasText = !trimmedMessageText.isEmpty
|
let hasText = !trimmedMessageText.isEmpty
|
||||||
return ZStack {
|
if shouldShowVoiceControl {
|
||||||
micButtonView
|
ZStack {
|
||||||
.opacity(hasText ? 0 : 1)
|
micButtonView
|
||||||
.allowsHitTesting(!hasText)
|
.opacity(hasText ? 0 : 1)
|
||||||
|
.allowsHitTesting(!hasText)
|
||||||
|
sendButtonView(enabled: hasText)
|
||||||
|
.opacity(hasText ? 1 : 0)
|
||||||
|
.allowsHitTesting(hasText)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
} else {
|
||||||
sendButtonView(enabled: hasText)
|
sendButtonView(enabled: hasText)
|
||||||
.opacity(hasText ? 1 : 0)
|
.frame(width: 36, height: 36)
|
||||||
.allowsHitTesting(hasText)
|
|
||||||
}
|
}
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var micButtonView: some View {
|
private var micButtonView: some View {
|
||||||
@@ -1920,6 +1983,7 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startVoiceRecording() {
|
func startVoiceRecording() {
|
||||||
|
guard shouldShowVoiceControl else { return }
|
||||||
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
||||||
isPreparingVoiceNote = true
|
isPreparingVoiceNote = true
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FingerprintView: View {
|
struct FingerprintView: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -65,15 +65,12 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Prefer short mesh ID for session/encryption status
|
||||||
let statusPeerID: String = {
|
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
|
||||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
|
|
||||||
return peerID
|
|
||||||
}()
|
|
||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
|
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
if let data = peerID.noiseKey {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
let fp = data.sha256Fingerprint()
|
let fp = data.sha256Fingerprint()
|
||||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
@@ -84,7 +81,7 @@ struct FingerprintView: View {
|
|||||||
return Strings.unknownPeer()
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -115,7 +112,7 @@ struct FingerprintView: View {
|
|||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
|
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -176,7 +173,6 @@ struct FingerprintView: View {
|
|||||||
// Verification status
|
// Verification status
|
||||||
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
let peerID = PeerID(str: peerID)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
@@ -240,8 +236,6 @@ struct FingerprintView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatFingerprint(_ fingerprint: String) -> String {
|
private func formatFingerprint(_ fingerprint: String) -> String {
|
||||||
|
|||||||
@@ -125,9 +125,6 @@ struct LocationChannelsSheet: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
#endif
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ struct LocationNotesView: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
#endif
|
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.onDisappear { manager.cancel() }
|
.onDisappear { manager.cancel() }
|
||||||
.onChange(of: geohash) { newValue in
|
.onChange(of: geohash) { newValue in
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ struct MeshPeerList: View {
|
|||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let secondaryTextColor: Color
|
let secondaryTextColor: Color
|
||||||
let onTapPeer: (String) -> Void
|
let onTapPeer: (PeerID) -> Void
|
||||||
let onToggleFavorite: (String) -> Void
|
let onToggleFavorite: (PeerID) -> Void
|
||||||
let onShowFingerprint: (String) -> Void
|
let onShowFingerprint: (PeerID) -> Void
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
Button(action: { onToggleFavorite(peer.peerID) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||||
@@ -142,8 +142,8 @@ struct MeshPeerList: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 10 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
|
|||||||
@@ -388,10 +388,6 @@ struct VerificationSheetView: View {
|
|||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
#endif
|
|
||||||
.onDisappear { showingScanner = false }
|
.onDisappear { showingScanner = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,5 +298,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,11 +186,11 @@ struct PrivateChatE2ETests {
|
|||||||
// Bob relays private messages for Charlie
|
// Bob relays private messages for Charlie
|
||||||
bob.packetDeliveryHandler = { packet in
|
bob.packetDeliveryHandler = { packet in
|
||||||
if let recipientID = packet.recipientID,
|
if let recipientID = packet.recipientID,
|
||||||
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
PeerID(data: recipientID) == charlie.peerID {
|
||||||
// Relay to Charlie
|
// Relay to Charlie
|
||||||
var relayPacket = packet
|
var relayPacket = packet
|
||||||
relayPacket.ttl = packet.ttl - 1
|
relayPacket.ttl = packet.ttl - 1
|
||||||
self.charlie.simulateIncomingPacket(relayPacket)
|
charlie.simulateIncomingPacket(relayPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
|
|||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeerID?.id != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ extension FragmentationTests {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
publicMessages.append((peerID, nickname, content))
|
publicMessages.append((peerID, nickname, content))
|
||||||
}
|
}
|
||||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||||
|
|||||||
@@ -125,16 +125,138 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
#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 {
|
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||||
var onSend: (() -> Void)?
|
var onSend: (() -> Void)?
|
||||||
private(set) var lastPacket: BitchatPacket?
|
private(set) var lastPacket: BitchatPacket?
|
||||||
|
private(set) var packets: [BitchatPacket] = []
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
|
||||||
func sendPacket(_ packet: BitchatPacket) {
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
lastPacket = packet
|
lastPacket = packet
|
||||||
|
packets.append(packet)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
onSend?()
|
onSend?()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// 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,10 +203,7 @@ final class MockBLEService: NSObject {
|
|||||||
let target = bus.service(for: recipientPeerID) {
|
let target = bus.service(for: recipientPeerID) {
|
||||||
target.simulateIncomingPacket(packet)
|
target.simulateIncomingPacket(packet)
|
||||||
} else {
|
} else {
|
||||||
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
// Not directly connected: deliver to neighbors for relay
|
||||||
if let target = bus.service(for: recipientPeerID) {
|
|
||||||
target.simulateIncomingPacket(packet)
|
|
||||||
}
|
|
||||||
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
||||||
neighbor.simulateIncomingPacket(packet)
|
neighbor.simulateIncomingPacket(packet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ struct NostrProtocolTests {
|
|||||||
|
|
||||||
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||||
let messageID = "TEST-MSG-DELIVERED-1"
|
let messageID = "TEST-MSG-DELIVERED-1"
|
||||||
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
let senderPeerID = PeerID(str: "0123456789abcdef") // 8-byte hex peer ID
|
||||||
|
|
||||||
let embedded = try #require(
|
let embedded = try #require(
|
||||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
||||||
@@ -176,7 +176,7 @@ struct NostrProtocolTests {
|
|||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
let messageID = "TEST-MSG-READ-1"
|
let messageID = "TEST-MSG-READ-1"
|
||||||
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
let senderPeerID = PeerID(str: "fedcba9876543210") // 8-byte hex peer ID
|
||||||
let embedded = try #require(
|
let embedded = try #require(
|
||||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
||||||
"Failed to embed read ack"
|
"Failed to embed read ack"
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ struct PeerIDTests {
|
|||||||
@Test func comparable_sorting_and_equality() {
|
@Test func comparable_sorting_and_equality() {
|
||||||
let p1 = PeerID(str: "aaa")
|
let p1 = PeerID(str: "aaa")
|
||||||
let p2 = PeerID(str: "bbb")
|
let p2 = PeerID(str: "bbb")
|
||||||
let p3 = PeerID(str: "bbb")
|
let p3 = PeerID(str: "BBB")
|
||||||
|
|
||||||
#expect(p1 < p2)
|
#expect(p1 < p2)
|
||||||
#expect(p2 >= p1)
|
#expect(p2 >= p1)
|
||||||
@@ -284,44 +284,18 @@ struct PeerIDTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func equality() {
|
@Test func equality() {
|
||||||
let string = "aaa"
|
let peerID = PeerID(str: "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
|
// Regular PeerID <> PeerID
|
||||||
#expect(peerID == PeerID(str: "aaa"))
|
#expect(peerID == PeerID(str: "AAA"))
|
||||||
#expect(peerID == Optional(PeerID(str: "aaa")))
|
#expect(peerID == Optional(PeerID(str: "AAA")))
|
||||||
#expect(PeerID(str: "aaa") == peerID)
|
#expect(PeerID(str: "AAA") == peerID)
|
||||||
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
|
#expect(Optional(PeerID(str: "AAA")) == Optional(peerID))
|
||||||
|
|
||||||
#expect(peerID != PeerID(str: "bbb"))
|
#expect(peerID != PeerID(str: "BBB"))
|
||||||
#expect(peerID != Optional(PeerID(str: "bbb")))
|
#expect(peerID != Optional(PeerID(str: "BBB")))
|
||||||
#expect(PeerID(str: "bbb") != peerID)
|
#expect(PeerID(str: "BBB") != peerID)
|
||||||
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
|
#expect(Optional(PeerID(str: "BBB")) != Optional(peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed properties
|
// MARK: - Computed properties
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "BitLogger",
|
name: "BitLogger",
|
||||||
path: "Sources"
|
path: "Sources"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "BitLoggerTests",
|
||||||
|
dependencies: ["BitLogger"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,22 +68,6 @@ public final class SecureLogger {
|
|||||||
return formatter
|
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
|
// MARK: - Log Levels
|
||||||
|
|
||||||
enum LogLevel {
|
enum LogLevel {
|
||||||
@@ -161,8 +145,8 @@ public extension SecureLogger {
|
|||||||
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize(context())
|
let sanitized = context().sanitized()
|
||||||
let errorDesc = sanitize(error.localizedDescription)
|
let errorDesc = error.localizedDescription.sanitized()
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
@@ -186,15 +170,15 @@ public extension SecureLogger {
|
|||||||
var message: String {
|
var message: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .handshakeStarted(let peerID):
|
case .handshakeStarted(let peerID):
|
||||||
return "Handshake started with peer: \(sanitize(peerID))"
|
return "Handshake started with peer: \(peerID.sanitized())"
|
||||||
case .handshakeCompleted(let peerID):
|
case .handshakeCompleted(let peerID):
|
||||||
return "Handshake completed with peer: \(sanitize(peerID))"
|
return "Handshake completed with peer: \(peerID.sanitized())"
|
||||||
case .handshakeFailed(let peerID, let error):
|
case .handshakeFailed(let peerID, let error):
|
||||||
return "Handshake failed with peer: \(sanitize(peerID)), error: \(error)"
|
return "Handshake failed with peer: \(peerID.sanitized()), error: \(error)"
|
||||||
case .sessionExpired(let peerID):
|
case .sessionExpired(let peerID):
|
||||||
return "Session expired for peer: \(sanitize(peerID))"
|
return "Session expired for peer: \(peerID.sanitized())"
|
||||||
case .authenticationFailed(let peerID):
|
case .authenticationFailed(let peerID):
|
||||||
return "Authentication failed for peer: \(sanitize(peerID))"
|
return "Authentication failed for peer: \(peerID.sanitized())"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +233,7 @@ private extension SecureLogger {
|
|||||||
file: String, line: Int, function: String) {
|
file: String, line: Int, function: String) {
|
||||||
guard shouldLog(level) else { return }
|
guard shouldLog(level) else { return }
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize("\(location) \(message())")
|
let sanitized = "\(location) \(message())".sanitized()
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||||
@@ -282,58 +266,6 @@ private extension SecureLogger {
|
|||||||
let timestamp = timestampFormatter.string(from: Date())
|
let timestamp = timestampFormatter.string(from: Date())
|
||||||
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
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
|
// MARK: - Migration Helper
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// 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