mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 23:25:20 +00:00
adjust TLV sizes
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
// BitchatFilePacket.swift
|
// BitchatFilePacket.swift
|
||||||
// bitchat
|
// bitchat
|
||||||
//
|
//
|
||||||
// TLV encoder/decoder for file transfer payloads (audio/images)
|
// TLV encoder/decoder for file transfer payloads (images, audio, and generic files)
|
||||||
// Types: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (8 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (raw)
|
// v2 Spec: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (4 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (4-byte len)
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -18,29 +18,51 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fileName: String
|
let fileName: String
|
||||||
let fileSize: UInt64
|
let fileSize: UInt32
|
||||||
let mimeType: String
|
let mimeType: String
|
||||||
let content: Data
|
let content: Data
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
var out = 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)
|
out.append(type.rawValue)
|
||||||
let len = UInt16(min(value.count, 0xFFFF))
|
let len = UInt16(min(value.count, 0xFFFF))
|
||||||
out.append(UInt8((len >> 8) & 0xFF))
|
out.append(UInt8((len >> 8) & 0xFF))
|
||||||
out.append(UInt8(len & 0xFF))
|
out.append(UInt8(len & 0xFF))
|
||||||
out.append(value.prefix(Int(len)))
|
out.append(value.prefix(Int(len)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// FILE_NAME
|
// FILE_NAME
|
||||||
if let nameData = fileName.data(using: .utf8) { appendTLV(.fileName, nameData) }
|
if let nameData = fileName.data(using: .utf8) {
|
||||||
// FILE_SIZE (8 bytes, BE)
|
appendStandardTLV(.fileName, nameData)
|
||||||
var sizeBE = Data()
|
}
|
||||||
for i in (0..<8).reversed() { sizeBE.append(UInt8((fileSize >> (i * 8)) & 0xFF)) }
|
|
||||||
appendTLV(.fileSize, sizeBE)
|
// 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
|
// MIME_TYPE
|
||||||
if let mimeData = mimeType.data(using: .utf8) { appendTLV(.mimeType, mimeData) }
|
if let mimeData = mimeType.data(using: .utf8) {
|
||||||
// CONTENT
|
appendStandardTLV(.mimeType, mimeData)
|
||||||
appendTLV(.content, content)
|
}
|
||||||
|
|
||||||
|
// 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,37 +78,55 @@ struct BitchatFilePacket {
|
|||||||
guard let d = read(2) else { return nil }
|
guard let d = read(2) else { return nil }
|
||||||
return (UInt16(d[0]) << 8) | UInt16(d[1])
|
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 name: String?
|
||||||
var size: UInt64?
|
var size: UInt32?
|
||||||
var mime: String?
|
var mime: String?
|
||||||
var content = Data()
|
var contentData = Data()
|
||||||
|
|
||||||
while idx < data.count {
|
while idx < data.count {
|
||||||
guard let t = read8(), let tlvType = TLVType(rawValue: t), let len = read16() else { return nil }
|
guard let t = read8(), let tlvType = TLVType(rawValue: t) else { return nil }
|
||||||
let l = Int(len)
|
|
||||||
guard let val = read(l) 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 {
|
switch tlvType {
|
||||||
case .fileName:
|
case .fileName:
|
||||||
name = String(data: val, encoding: .utf8)
|
name = String(data: val, encoding: .utf8)
|
||||||
case .fileSize:
|
case .fileSize:
|
||||||
guard l == 8 else { return nil }
|
guard len == 4 else { return nil } // v2 spec: 4 bytes
|
||||||
var s: UInt64 = 0
|
var s: UInt32 = 0
|
||||||
for b in val { s = (s << 8) | UInt64(b) }
|
for b in val { s = (s << 8) | UInt32(b) }
|
||||||
size = s
|
size = s
|
||||||
case .mimeType:
|
case .mimeType:
|
||||||
mime = String(data: val, encoding: .utf8)
|
mime = String(data: val, encoding: .utf8)
|
||||||
case .content:
|
case .content:
|
||||||
content = val
|
// Expect single CONTENT TLV; if multiple, concatenate defensively
|
||||||
|
contentData.append(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
guard !content.isEmpty else { return nil }
|
guard !contentData.isEmpty else { return nil }
|
||||||
let finalSize = size ?? UInt64(content.count)
|
let finalSize = size ?? UInt32(contentData.count)
|
||||||
let finalName = name ?? "file"
|
let finalName = name ?? "file"
|
||||||
let finalMime = mime ?? "application/octet-stream"
|
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 name = fileURL.lastPathComponent
|
||||||
let mime = "image/jpeg"
|
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 }
|
guard let payload = tlv.encode() else { return }
|
||||||
let transferId = payload.sha256Hex()
|
let transferId = payload.sha256Hex()
|
||||||
|
|
||||||
|
|||||||
@@ -3771,7 +3771,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||||
if let spid = message.senderPeerID {
|
if let spid = message.senderPeerID {
|
||||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||||
let bare: String = {
|
let bare: String = {
|
||||||
@@ -6060,7 +6060,7 @@ private func checkForMentions(_ message: BitchatMessage) {
|
|||||||
guard let data = try? Data(contentsOf: fileURL) else { return }
|
guard let data = try? Data(contentsOf: fileURL) else { return }
|
||||||
let name = fileURL.lastPathComponent
|
let name = fileURL.lastPathComponent
|
||||||
let mime = "audio/mp4"
|
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 }
|
guard let payload = tlv.encode() else { return }
|
||||||
let transferId = payload.sha256Hex()
|
let transferId = payload.sha256Hex()
|
||||||
|
|
||||||
@@ -6104,102 +6104,8 @@ private func checkForMentions(_ message: BitchatMessage) {
|
|||||||
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
|
meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Images (Send)
|
// Images code moved to ChatViewModel+Images.swift
|
||||||
#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
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func appendToPrivateChat(peerID: String, message: BitchatMessage) {
|
private func appendToPrivateChat(peerID: String, message: BitchatMessage) {
|
||||||
|
|||||||
@@ -328,6 +328,9 @@ struct ContentView: View {
|
|||||||
let isImage = message.content.hasPrefix("[image] ")
|
let isImage = message.content.hasPrefix("[image] ")
|
||||||
|
|
||||||
if isVoice {
|
if isVoice {
|
||||||
|
// Header line above media: sender + timestamp
|
||||||
|
MessageHeaderLine(message: message)
|
||||||
|
.environmentObject(viewModel)
|
||||||
// Hide the raw marker text; render custom voice view
|
// Hide the raw marker text; render custom voice view
|
||||||
HStack(alignment: .center, spacing: 8) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
let path = String(message.content.dropFirst("[voice] ".count))
|
let path = String(message.content.dropFirst("[voice] ".count))
|
||||||
@@ -349,40 +352,13 @@ struct ContentView: View {
|
|||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
} else if isImage {
|
} else if isImage {
|
||||||
|
// Header line above image: sender + timestamp
|
||||||
|
MessageHeaderLine(message: message)
|
||||||
|
.environmentObject(viewModel)
|
||||||
// Hide the raw marker text; render image view
|
// Hide the raw marker text; render image view
|
||||||
let path = String(message.content.dropFirst("[image] ".count))
|
let path = String(message.content.dropFirst("[image] ".count))
|
||||||
#if os(iOS)
|
ImageMessageRow(path: path, message: message)
|
||||||
if let uiImage = UIImage(contentsOfFile: path) {
|
.environmentObject(viewModel)
|
||||||
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
|
|
||||||
} else {
|
} else {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||||
|
|||||||
Reference in New Issue
Block a user