mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 17:45:21 +00:00
adjust TLV sizes
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
// BitchatFilePacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// TLV encoder/decoder for file transfer payloads (audio/images)
|
||||
// Types: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (8 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (raw)
|
||||
// TLV encoder/decoder for file transfer payloads (images, audio, and generic files)
|
||||
// v2 Spec: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (4 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (4-byte len)
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -18,29 +18,51 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
let fileName: String
|
||||
let fileSize: UInt64
|
||||
let fileSize: UInt32
|
||||
let mimeType: String
|
||||
let content: Data
|
||||
|
||||
func encode() -> Data? {
|
||||
var out = Data()
|
||||
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||
|
||||
// Standard TLV helper for FILE_NAME and MIME_TYPE (2-byte length)
|
||||
func appendStandardTLV(_ type: TLVType, _ value: Data) {
|
||||
out.append(type.rawValue)
|
||||
let len = UInt16(min(value.count, 0xFFFF))
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(value.prefix(Int(len)))
|
||||
}
|
||||
|
||||
// FILE_NAME
|
||||
if let nameData = fileName.data(using: .utf8) { appendTLV(.fileName, nameData) }
|
||||
// FILE_SIZE (8 bytes, BE)
|
||||
var sizeBE = Data()
|
||||
for i in (0..<8).reversed() { sizeBE.append(UInt8((fileSize >> (i * 8)) & 0xFF)) }
|
||||
appendTLV(.fileSize, sizeBE)
|
||||
if let nameData = fileName.data(using: .utf8) {
|
||||
appendStandardTLV(.fileName, nameData)
|
||||
}
|
||||
|
||||
// FILE_SIZE (4 bytes, UInt32 BE) - v2 spec
|
||||
out.append(TLVType.fileSize.rawValue)
|
||||
out.append(UInt8(0)) // Length high byte = 0
|
||||
out.append(UInt8(4)) // Length low byte = 4
|
||||
let size32 = UInt32(min(fileSize, UInt32.max))
|
||||
out.append(UInt8((size32 >> 24) & 0xFF))
|
||||
out.append(UInt8((size32 >> 16) & 0xFF))
|
||||
out.append(UInt8((size32 >> 8) & 0xFF))
|
||||
out.append(UInt8(size32 & 0xFF))
|
||||
|
||||
// MIME_TYPE
|
||||
if let mimeData = mimeType.data(using: .utf8) { appendTLV(.mimeType, mimeData) }
|
||||
// CONTENT
|
||||
appendTLV(.content, content)
|
||||
if let mimeData = mimeType.data(using: .utf8) {
|
||||
appendStandardTLV(.mimeType, mimeData)
|
||||
}
|
||||
|
||||
// CONTENT (4-byte length) - v2 spec
|
||||
out.append(TLVType.content.rawValue)
|
||||
let contentLen = UInt32(content.count)
|
||||
out.append(UInt8((contentLen >> 24) & 0xFF))
|
||||
out.append(UInt8((contentLen >> 16) & 0xFF))
|
||||
out.append(UInt8((contentLen >> 8) & 0xFF))
|
||||
out.append(UInt8(contentLen & 0xFF))
|
||||
out.append(content)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -56,37 +78,55 @@ struct BitchatFilePacket {
|
||||
guard let d = read(2) else { return nil }
|
||||
return (UInt16(d[0]) << 8) | UInt16(d[1])
|
||||
}
|
||||
func read32() -> UInt32? {
|
||||
guard let d = read(4) else { return nil }
|
||||
return (UInt32(d[0]) << 24) | (UInt32(d[1]) << 16) | (UInt32(d[2]) << 8) | UInt32(d[3])
|
||||
}
|
||||
|
||||
var name: String?
|
||||
var size: UInt64?
|
||||
var size: UInt32?
|
||||
var mime: String?
|
||||
var content = Data()
|
||||
var contentData = Data()
|
||||
|
||||
while idx < data.count {
|
||||
guard let t = read8(), let tlvType = TLVType(rawValue: t), let len = read16() else { return nil }
|
||||
let l = Int(len)
|
||||
guard let val = read(l) else { return nil }
|
||||
guard let t = read8(), let tlvType = TLVType(rawValue: t) else { return nil }
|
||||
|
||||
// CONTENT uses 4-byte length; others use 2-byte length
|
||||
let len: Int
|
||||
if tlvType == .content {
|
||||
guard let len32 = read32() else { return nil }
|
||||
len = Int(len32)
|
||||
} else {
|
||||
guard let len16 = read16() else { return nil }
|
||||
len = Int(len16)
|
||||
}
|
||||
|
||||
guard len >= 0, idx + len <= data.count else { return nil }
|
||||
let val = data.subdata(in: idx..<(idx+len))
|
||||
idx += len
|
||||
|
||||
switch tlvType {
|
||||
case .fileName:
|
||||
name = String(data: val, encoding: .utf8)
|
||||
case .fileSize:
|
||||
guard l == 8 else { return nil }
|
||||
var s: UInt64 = 0
|
||||
for b in val { s = (s << 8) | UInt64(b) }
|
||||
guard len == 4 else { return nil } // v2 spec: 4 bytes
|
||||
var s: UInt32 = 0
|
||||
for b in val { s = (s << 8) | UInt32(b) }
|
||||
size = s
|
||||
case .mimeType:
|
||||
mime = String(data: val, encoding: .utf8)
|
||||
case .content:
|
||||
content = val
|
||||
// Expect single CONTENT TLV; if multiple, concatenate defensively
|
||||
contentData.append(val)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate
|
||||
guard !content.isEmpty else { return nil }
|
||||
let finalSize = size ?? UInt64(content.count)
|
||||
guard !contentData.isEmpty else { return nil }
|
||||
let finalSize = size ?? UInt32(contentData.count)
|
||||
let finalName = name ?? "file"
|
||||
let finalMime = mime ?? "application/octet-stream"
|
||||
return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: content)
|
||||
return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: contentData)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ extension ChatViewModel {
|
||||
|
||||
let name = fileURL.lastPathComponent
|
||||
let mime = "image/jpeg"
|
||||
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data)
|
||||
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data)
|
||||
guard let payload = tlv.encode() else { return }
|
||||
let transferId = payload.sha256Hex()
|
||||
|
||||
|
||||
@@ -3771,7 +3771,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
if let spid = message.senderPeerID {
|
||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||
let bare: String = {
|
||||
@@ -6060,7 +6060,7 @@ private func checkForMentions(_ message: BitchatMessage) {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return }
|
||||
let name = fileURL.lastPathComponent
|
||||
let mime = "audio/mp4"
|
||||
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data)
|
||||
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data)
|
||||
guard let payload = tlv.encode() else { return }
|
||||
let transferId = payload.sha256Hex()
|
||||
|
||||
@@ -6104,102 +6104,8 @@ private func checkForMentions(_ message: BitchatMessage) {
|
||||
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Images (Send)
|
||||
#if os(iOS)
|
||||
@MainActor
|
||||
func sendImage(_ image: UIImage) {
|
||||
// Downscale and encode JPEG
|
||||
guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return }
|
||||
|
||||
// Persist to app files (outgoing)
|
||||
guard let fileURL = saveOutgoingImage(data: data) else { return }
|
||||
|
||||
let name = fileURL.lastPathComponent
|
||||
let mime = "image/jpeg"
|
||||
let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data)
|
||||
guard let payload = tlv.encode() else { return }
|
||||
let transferId = payload.sha256Hex()
|
||||
|
||||
let contentMarker = "[image] \(fileURL.path)"
|
||||
let now = Date()
|
||||
|
||||
if let peer = selectedPrivateChatPeer {
|
||||
let messageID = UUID().uuidString
|
||||
let msg = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: contentMarker,
|
||||
timestamp: now,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: meshService.peerNickname(peerID: peer),
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
appendToPrivateChat(peerID: peer, message: msg)
|
||||
meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID)
|
||||
} else {
|
||||
let messageID = UUID().uuidString
|
||||
let msg = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: contentMarker,
|
||||
timestamp: now,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
publicBuffer.append(msg)
|
||||
schedulePublicFlush()
|
||||
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
private func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? {
|
||||
let size = image.size
|
||||
guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) }
|
||||
let maxSide = max(size.width, size.height)
|
||||
let scale = min(1.0, maxDimension / maxSide)
|
||||
let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale))
|
||||
|
||||
let rendererFormat = UIGraphicsImageRendererFormat.default()
|
||||
rendererFormat.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat)
|
||||
let scaled = renderer.image { ctx in
|
||||
UIColor.clear.setFill()
|
||||
ctx.fill(CGRect(origin: .zero, size: target))
|
||||
image.draw(in: CGRect(origin: .zero, size: target))
|
||||
}
|
||||
return scaled.jpegData(compressionQuality: quality)
|
||||
}
|
||||
|
||||
private func saveOutgoingImage(data: Data) -> URL? {
|
||||
do {
|
||||
let fm = FileManager.default
|
||||
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let folder = base.appendingPathComponent("bitchat_images/outgoing", isDirectory: true)
|
||||
if !fm.fileExists(atPath: folder.path) {
|
||||
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
let ts = Int(Date().timeIntervalSince1970)
|
||||
let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg")
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
return fileURL
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to save outgoing image: \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Images code moved to ChatViewModel+Images.swift
|
||||
|
||||
@MainActor
|
||||
private func appendToPrivateChat(peerID: String, message: BitchatMessage) {
|
||||
|
||||
@@ -328,6 +328,9 @@ struct ContentView: View {
|
||||
let isImage = message.content.hasPrefix("[image] ")
|
||||
|
||||
if isVoice {
|
||||
// Header line above media: sender + timestamp
|
||||
MessageHeaderLine(message: message)
|
||||
.environmentObject(viewModel)
|
||||
// Hide the raw marker text; render custom voice view
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let path = String(message.content.dropFirst("[voice] ".count))
|
||||
@@ -349,40 +352,13 @@ struct ContentView: View {
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
} else if isImage {
|
||||
// Header line above image: sender + timestamp
|
||||
MessageHeaderLine(message: message)
|
||||
.environmentObject(viewModel)
|
||||
// Hide the raw marker text; render image view
|
||||
let path = String(message.content.dropFirst("[image] ".count))
|
||||
#if os(iOS)
|
||||
if let uiImage = UIImage(contentsOfFile: path) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Image with rounded corners and left alignment
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: 200, maxHeight: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 2)
|
||||
|
||||
// Delivery status for our own private images
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
// Fallback for invalid image files
|
||||
Text("⚠️ Image unavailable")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
#else
|
||||
// macOS: show path for now since NSImage loading is different
|
||||
Text("📷 Image: \(path)")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
ImageMessageRow(path: path, message: message)
|
||||
.environmentObject(viewModel)
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||
|
||||
Reference in New Issue
Block a user