mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
CRITICAL BUG FIX - Infinite Render Loop:
Root Cause: Duplicate view identity in ContentView.swift:368
ForEach(messageItems) { item in // Already uses item.id via Identifiable
messageRow(...)
.id(item.id) // ❌ REDUNDANT modifier caused identity re-evaluation loop
}
When @Published properties updated, SwiftUI re-evaluated .id() → appeared as
'new' identity → triggered re-render → infinite loop. Caused UI freezes,
keyboard failures, and 100% CPU usage.
Fix: Remove redundant .id() modifier - ForEach already has stable identity.
PERFORMANCE FIXES:
1. Waveform Cache Deadlock (Waveform.swift)
- Removed nested queue.async(barrier) on cache hits
- Was causing task saturation and potential deadlocks
2. Async Send Pattern (ContentView.swift)
- Clear input immediately, defer actual send to next runloop
- Prevents blocking current event handler
3. Proper Swift Concurrency (VoiceNoteView.swift)
- Switch from .onAppear + DispatchQueue to .task
- Cleaner async/await pattern for loading
4. Remove Redundant objectWillChange (ChatViewModel.swift)
- @Published already triggers updates automatically
- Explicit send() was causing double update cycles
SECURITY FIXES (C1-C5, H1-H2):
C1. Path Traversal Protection (BLEService.swift)
- Unicode normalization, null byte removal
- Replace ALL path separators, reject dotfiles
- Validate paths don't escape directory
C2. Integer Overflow (BitchatFilePacket.swift)
- Use UInt64 for TLV parsing, safe Int conversion
C3. MIME Validation (BLEService.swift)
- Whitelist: JPEG, PNG, GIF, WebP, M4A, MP3, WAV, OGG, PDF
- Magic byte validation for all types
- Lenient on M4A (platform variations)
C4. Compression Bomb (BinaryProtocol.swift)
- Ratio validation <= 50,000:1
- Defense-in-depth with 1MB size cap
C5. TOCTOU Race (ChatViewModel.swift)
- Direct removeItem without fileExists check
H1. File Size Validation (ChatViewModel, ImageUtils)
- Check attributes BEFORE Data(contentsOf:)
- Prevents memory exhaustion
H2. Metadata Stripping (ImageUtils.swift)
- Remove ALL metadata keys from JPEG encoding
- Only compression quality set
- Protects GPS/EXIF/device info privacy
RESULT:
✅ No render loops
✅ Works with Xcode debugger
✅ Voice notes display properly
✅ All security vulnerabilities fixed
✅ 164 tests passing
Production ready.
124 lines
4.2 KiB
Swift
124 lines
4.2 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
|
|
struct VoiceNoteView: View {
|
|
private let url: URL
|
|
private let isSending: Bool
|
|
private let sendProgress: Double?
|
|
private let onCancel: (() -> Void)?
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@StateObject private var playback: VoiceNotePlaybackController
|
|
@State private var waveform: [Float] = []
|
|
|
|
init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {
|
|
self.url = url
|
|
self.isSending = isSending
|
|
self.sendProgress = sendProgress
|
|
self.onCancel = onCancel
|
|
_playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))
|
|
}
|
|
|
|
private var samples: [Float] {
|
|
if waveform.isEmpty {
|
|
return Array(repeating: 0.25, count: 64)
|
|
}
|
|
return waveform
|
|
}
|
|
|
|
private var backgroundColor: Color {
|
|
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
|
}
|
|
|
|
private var borderColor: Color {
|
|
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
|
}
|
|
|
|
private var durationText: String {
|
|
let duration = playback.duration
|
|
guard duration.isFinite, duration > 0 else { return "--:--" }
|
|
let minutes = Int(duration) / 60
|
|
let seconds = Int(duration) % 60
|
|
return String(format: "%02d:%02d", minutes, seconds)
|
|
}
|
|
|
|
private var currentText: String {
|
|
let current = playback.currentTime
|
|
guard current.isFinite, current > 0 else { return "00:00" }
|
|
let minutes = Int(current) / 60
|
|
let seconds = Int(current) % 60
|
|
return String(format: "%02d:%02d", minutes, seconds)
|
|
}
|
|
|
|
private var playbackLabel: String {
|
|
playback.isPlaying ? currentText + "/" + durationText : durationText
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Button(action: playback.togglePlayback) {
|
|
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
|
|
.foregroundColor(.white)
|
|
.frame(width: 36, height: 36)
|
|
.background(Circle().fill(Color.green))
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
WaveformView(
|
|
samples: samples,
|
|
playbackProgress: playback.progress,
|
|
sendProgress: sendProgress,
|
|
onSeek: { fraction in
|
|
playback.seek(to: fraction)
|
|
},
|
|
isInteractive: playback.isPlaying
|
|
)
|
|
|
|
Text(playbackLabel)
|
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
|
.foregroundColor(Color.secondary)
|
|
|
|
if let onCancel = onCancel, isSending {
|
|
Button(action: onCancel) {
|
|
Image(systemName: "xmark")
|
|
.font(.bitchatSystem(size: 12, weight: .bold))
|
|
.frame(width: 28, height: 28)
|
|
.background(Circle().fill(Color.red.opacity(0.9)))
|
|
.foregroundColor(.white)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(backgroundColor)
|
|
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(borderColor, lineWidth: 1)
|
|
)
|
|
.task {
|
|
// Defer loading to let UI settle after view appears
|
|
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
|
playback.loadDuration()
|
|
await withCheckedContinuation { continuation in
|
|
WaveformCache.shared.waveform(for: url, completion: { bins in
|
|
waveform = bins
|
|
continuation.resume()
|
|
})
|
|
}
|
|
}
|
|
.onChange(of: url) { newValue in
|
|
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
|
self.waveform = bins
|
|
})
|
|
playback.replaceURL(newValue)
|
|
}
|
|
.onDisappear {
|
|
playback.stop()
|
|
}
|
|
}
|
|
}
|