diff options
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/HotlineApp.swift | 26 | ||||
| -rw-r--r-- | Hotline/Item.swift | 18 | ||||
| -rw-r--r-- | Hotline/Network/HotlineClient.swift | 241 | ||||
| -rw-r--r-- | Hotline/Network/HotlineProtocol.swift | 118 | ||||
| -rw-r--r-- | Hotline/Network/HotlineTracker.swift | 48 | ||||
| -rw-r--r-- | Hotline/TrackerView.swift | 139 | ||||
| -rw-r--r-- | Hotline/Utility/DataExtensions.swift | 42 |
7 files changed, 513 insertions, 119 deletions
diff --git a/Hotline/HotlineApp.swift b/Hotline/HotlineApp.swift index f351e67..da49fd0 100644 --- a/Hotline/HotlineApp.swift +++ b/Hotline/HotlineApp.swift @@ -3,23 +3,23 @@ import SwiftData @main struct HotlineApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() +// var sharedModelContainer: ModelContainer = { +// let schema = Schema([ +// Item.self, +// ]) +// let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) +// +// do { +// return try ModelContainer(for: schema, configurations: [modelConfiguration]) +// } catch { +// fatalError("Could not create ModelContainer: \(error)") +// } +// }() var body: some Scene { WindowGroup { TrackerView() } - .modelContainer(sharedModelContainer) +// .modelContainer(sharedModelContainer) } } diff --git a/Hotline/Item.swift b/Hotline/Item.swift deleted file mode 100644 index 4fa6e0c..0000000 --- a/Hotline/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// Hotline -// -// Created by Dustin Mierau on 11/25/23. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/Hotline/Network/HotlineClient.swift b/Hotline/Network/HotlineClient.swift new file mode 100644 index 0000000..d3b6c98 --- /dev/null +++ b/Hotline/Network/HotlineClient.swift @@ -0,0 +1,241 @@ +import Foundation +import Network + +enum HotlineClientStatus: Int { + case disconnected + case connecting + case connected +} + +class HotlineClient : ObservableObject { + static let shared = HotlineClient() + + static let handshakePacket = Data([ + 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID + 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID + 0x00, 0x01, // Version + 0x00, 0x02, // Sub-version + ]) + + @Published var connectionStatus: HotlineClientStatus = .disconnected + + var server: HotlineServer? + var connection: NWConnection? + var bytes = Data() + var handshakeComplete = false + + init() { + + } + + func connect(to server: HotlineServer) { + self.server = server + + let serverAddress = NWEndpoint.Host(server.address) + let serverPort = NWEndpoint.Port(rawValue: server.port)! + + self.connection = NWConnection(host: serverAddress, port: serverPort, using: .tcp) + self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in + switch newState { + case .ready: + print("HotlineClient: connection ready!") + DispatchQueue.main.async { + self?.connectionStatus = .connected + } + self?.sendHandshake() + case .cancelled: + print("HotlineClient: connection cancelled") + DispatchQueue.main.async { + self?.connectionStatus = .disconnected + } + case .failed(let err): + print("HotlineClient: connection error \(err)") + DispatchQueue.main.async { + self?.connectionStatus = .disconnected + } + default: + print("HotlineClient: unhandled connection state \(newState)") + } + } + + DispatchQueue.main.async { + self.connectionStatus = .connecting + } + self.connection?.start(queue: .global()) + } + + private func disconnect() { + guard let c = connection else { + print("HotlineClient: already disconnected") + return + } + + c.cancel() + self.connection = nil + } + + private func sendHandshake() { + guard let c = connection else { + print("HotlineClient: invalid connection to send handshake.") + return + } + + c.send(content: HotlineClient.handshakePacket, completion: .contentProcessed { [weak self] (error) in + if let err = error { + print("HotlineClient: sending magic failed \(err)") + return + } + + print("HotlineClient: sent handshake packet!") + + self?.receiveHandshake() + }) + } + + private func receiveHandshake() { + guard let c = connection else { + print("HotlineTracker: invalid connection to receive magic.") + return + } + + print("HotlineClient: receiving handshake...") + c.receive(minimumIncompleteLength: 8, maximumLength: 8) { [weak self] (data, context, isComplete, error) in + guard let self = self, let data = data else { + return + } + + if data.isEmpty { + print("HotlineClient: empty handshake response") + self.disconnect() + return + } + + let protocolID = data.readUInt32(at: 0)! + if protocolID != 0x54525450 { // 'TRTP' + print("HotlineClient: invalid handshake protocol ID \(protocolID)") + self.disconnect() + return + } + + let errorCode = data.readUInt32(at: 4)! + if errorCode != 0 { // 0 == no error + print("HotlineClient: handshake error", errorCode) + self.disconnect() + return + } + + print("HotlineClient: completed handshake") + self.sendLogin() + } + } + + private func sendLogin() { + guard let c = connection else { + print("HotlineClient: no connection for transaction.") + return + } + + c.send(content: HotlineClient.handshakePacket, completion: .contentProcessed { [weak self] (error) in + if let err = error { + print("HotlineClient: sending login failed \(err)") + return + } + + print("HotlineClient: sent handshake packet!") + + + }) + } + + private func receiveTransaction() { + guard let c = connection else { + print("HotlineClient: no connection for transaction.") + return + } + + print("HotlineClient: waiting on transaction header...") + c.receive(minimumIncompleteLength: HotlineTransaction.headerSize, maximumLength: HotlineTransaction.headerSize) { [weak self] (data, context, isComplete, error) in + guard let self = self else { + return + } + + if let error = error { + print("HotlineClient: receive error \(error)") + self.disconnect() + return + } + + if let data = data, !data.isEmpty { + print("HotlineClient: received \(data.count) header bytes") + + let transaction = self.parseTransaction(data: data) + if var t = transaction { + if t.dataSize > 0 { + c.receive(minimumIncompleteLength: Int(t.dataSize), maximumLength: Int(t.dataSize)) { [weak self] (data, context, isComplete, error) in + guard let self = self else { + return + } + + if let data = data, !data.isEmpty { + t.parameterCount = data.readUInt16(at: 0)! + + if t.parameterCount > 0 { + t.parameters = [] + var dataCursor = 2 + for _ in 0..<t.parameterCount { + if + let fieldID = data.readUInt16(at: dataCursor), + let fieldSize = data.readUInt16(at: dataCursor + 2), + let fieldData = data.readData(at: dataCursor + 4, length: Int(fieldSize)) { + t.parameters?.append(HotlineTransactionParameter(id: fieldID, dataSize: fieldSize, data: fieldData)) + + dataCursor += 4 + Int(fieldSize) + } + } + } + } + + self.processTransaction(t) + } + } + else { + self.processTransaction(t) + } + } + +// print("HotlineTracker: server count = \(self.serverCount)") + } + +// self.receiveListing() +// } + } + } + + private func processTransaction(_ transaction: HotlineTransaction) { + print("HotlineClient processing transaction \(transaction.type) with \(transaction.parameterCount) parameters") + } + + private func parseTransaction(data: Data) -> HotlineTransaction? { + if + let flags = data.readUInt8(at: 0), + let isReply = data.readUInt8(at: 1), + let type = data.readUInt16(at: 2), + let id = data.readUInt32(at: 4), + let errorCode = data.readUInt32(at: 8), + let transactionSize = data.readUInt32(at: 12), + let dataSize = data.readUInt32(at: 16) { + + return HotlineTransaction( + flags: flags, + isReply: isReply, + type: HotlineTransactionType(rawValue: type) ?? HotlineTransactionType.unknown, + id: id, + errorCode: errorCode, + totalSize: transactionSize, + dataSize: dataSize + ) + } + + return nil + } +} diff --git a/Hotline/Network/HotlineProtocol.swift b/Hotline/Network/HotlineProtocol.swift new file mode 100644 index 0000000..787675e --- /dev/null +++ b/Hotline/Network/HotlineProtocol.swift @@ -0,0 +1,118 @@ +import Foundation + +struct HotlineServer: Identifiable, Hashable { + let id = UUID() + let address: String + let port: UInt16 + let users: UInt16 + let name: String? + let description: String? + + static func == (lhs: HotlineServer, rhs: HotlineServer) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} + +struct HotlineTransactionParameter { + let id: UInt16 + let dataSize: UInt16 + let data: Data +} + +struct HotlineTransaction { + static let headerSize = 20 + + let flags: UInt8 + let isReply: UInt8 + let type: HotlineTransactionType + let id: UInt32 + let errorCode: UInt32 + let totalSize: UInt32 + let dataSize: UInt32 + + var parameterCount: UInt16 = 0 + var parameters: [HotlineTransactionParameter]? = nil + + func encode(to data: inout Data) { + data.appendUInt8(self.flags) + data.appendUInt8(self.isReply) + data.appendUInt16(self.type.rawValue) + data.appendUInt32(self.id) + data.appendUInt32(self.errorCode) + data.appendUInt32(self.totalSize) + data.appendUInt32(self.dataSize) + + if let p = self.parameters, p.count > 0 { + data.appendUInt16(UInt16(p.count)) + for param in p { + data.appendUInt16(param.id) + data.appendUInt16(param.dataSize) + data.append(param.data) + } + } + } +} + +enum HotlineTransactionType: UInt16 { + case unknown = 0 + case error = 100 + case getMessages = 101 + case newMessage = 102 + case oldPostNews = 103 + case serverMessage = 104 + case sendChat = 105 + case chatMessage = 106 + case login = 107 + case sendInstantMessage = 108 + case showAgreement = 109 + case disconnectUser = 110 + case disconnectMessage = 111 + case inviteToNewChat = 112 + case inviteToChat = 113 + case rejectChatInvite = 114 + case joinChat = 115 + case leaveChat = 116 + case notifyChatOfUserChange = 117 + case notifyChatOfUserDelete = 118 + case notifyChatSubject = 119 + case setChatSubject = 120 + case agreed = 121 + case serverBanner = 122 + case getFileNameList = 200 + case downloadFile = 202 + case uploadFile = 203 + case deleteFile = 204 + case newFolder = 205 + case getFileInfo = 206 + case setFileInfo = 207 + case moveFile = 208 + case makeFileAlias = 209 + case downloadFolder = 210 + case downloadInfo = 211 + case downloadBanner = 212 + case uploadFolder = 213 + case getUserNameList = 300 + case notifyOfUserChange = 301 + case notifyOfUserDelete = 302 + case getClientInfoText = 303 + case setClientUserInfo = 304 + case newUser = 350 + case deleteUser = 351 + case getUser = 352 + case setUser = 353 + case userAccess = 354 + case userBroadcast = 355 + case getNewsCategoryNameList = 370 + case getNewsArticleNameList = 371 + case deleteNewsItem = 380 + case newNewsFolder = 381 + case newNewsCategory = 382 + case getNewsArticleData = 400 + case postNewsArticle = 410 + case deleteNewsArticle = 411 +} + diff --git a/Hotline/Network/HotlineTracker.swift b/Hotline/Network/HotlineTracker.swift index 9b21ab2..2c44bc8 100644 --- a/Hotline/Network/HotlineTracker.swift +++ b/Hotline/Network/HotlineTracker.swift @@ -1,15 +1,6 @@ import Foundation import Network -struct HotlineServer: Identifiable { - var id = UUID() - let address: String - let port: UInt16 - let users: UInt16 - let name: String? - let description: String? -} - enum HotlineTrackerStatus: Int { case disconnected case connecting @@ -19,7 +10,6 @@ enum HotlineTrackerStatus: Int { class HotlineTracker : ObservableObject { let serverAddress: NWEndpoint.Host let serverPort: NWEndpoint.Port = NWEndpoint.Port(rawValue: 5498)! - let callback: ([HotlineServer]) -> Void static let magicPacket = Data([ 0x48, 0x54, 0x52, 0x4B, // 'HTRK' @@ -35,9 +25,8 @@ class HotlineTracker : ObservableObject { @Published var connectionStatus: HotlineTrackerStatus = .disconnected @Published var servers: [HotlineServer] = [] - init(address: String, callback: @escaping ([HotlineServer]) -> Void) { + init(address: String) { self.serverAddress = NWEndpoint.Host(address) - self.callback = callback } func fetch() { @@ -56,20 +45,28 @@ class HotlineTracker : ObservableObject { switch newState { case .ready: print("READY TO SEND AND RECEIVE DATA") - self?.connectionStatus = .connected + DispatchQueue.main.async { + self?.connectionStatus = .connected + } self?.sendMagic() case .cancelled: print("CONNECTION CANCELLED") - self?.connectionStatus = .disconnected + DispatchQueue.main.async { + self?.connectionStatus = .disconnected + } case .failed(let err): print("CONNECTION ERROR \(err)") - self?.connectionStatus = .disconnected + DispatchQueue.main.async { + self?.connectionStatus = .disconnected + } default: print("CONNECTION OTHER THING") } } - self.connectionStatus = .connecting + DispatchQueue.main.async { + self.connectionStatus = .connecting + } self.connection?.start(queue: .global()) } @@ -89,7 +86,7 @@ class HotlineTracker : ObservableObject { return } -// let packet: [UInt8] = [0x48, 0x54, 0x52, 0x4B, 0x00, self.serverVersion] + // let packet: [UInt8] = [0x48, 0x54, 0x52, 0x4B, 0x00, self.serverVersion] c.send(content: HotlineTracker.magicPacket, completion: .contentProcessed { [weak self] (error) in if let err = error { @@ -120,9 +117,9 @@ class HotlineTracker : ObservableObject { self.disconnect() return } -// if let data = data, !data.isEmpty { + // if let data = data, !data.isEmpty { print("HotlineTracker: received magic response!") -// } + // } if let error = error { print("HotlineTracker: receive error \(error)") @@ -153,7 +150,7 @@ class HotlineTracker : ObservableObject { if let data = data, !data.isEmpty { print("HotlineTracker: received \(data.count) header bytes") - + self.maxDataLength = Int(data[2]) * 0xFF + Int(data[3]) self.maxDataLength -= 4 print("HotlineTracker: message size = \(self.maxDataLength)") @@ -182,7 +179,7 @@ class HotlineTracker : ObservableObject { guard let self = self else { return } - + if let data = data, !data.isEmpty { print("HotlineTracker: received \(data.count) bytes") self.bytes.append(contentsOf: data) @@ -216,7 +213,7 @@ class HotlineTracker : ObservableObject { // Description size (1 byte) // Description (description size) - var servers: [HotlineServer] = [] + var foundServers: [HotlineServer] = [] var cursor = 0 for _ in 1...self.serverCount { @@ -243,7 +240,7 @@ class HotlineTracker : ObservableObject { print("SERVER: \(server)") - servers.append(server) + foundServers.append(server) cursor += 11 + nameLength + 1 + descLength } @@ -255,9 +252,8 @@ class HotlineTracker : ObservableObject { } DispatchQueue.main.async { - print("CALLING CALLBACK") - self.servers = servers - self.callback(servers) +// print("CALLING CALLBACK") + self.servers = foundServers } } diff --git a/Hotline/TrackerView.swift b/Hotline/TrackerView.swift index 04498b1..0bc9762 100644 --- a/Hotline/TrackerView.swift +++ b/Hotline/TrackerView.swift @@ -2,68 +2,115 @@ import SwiftUI import SwiftData struct TrackerView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] + // @Environment(\.modelContext) private var modelContext + // @Query private var items: [Item] - @StateObject var tracker = HotlineTracker(address: "hltracker.com", callback: { s in - print("ALL DONE") - }) + @StateObject var tracker = HotlineTracker(address: "hltracker.com") + @State private var selectedServer: HotlineServer? + + private var client: HotlineClient? var body: some View { - ScrollView { - VStack(alignment: .leading) { - ForEach(tracker.servers) { server in + List(selection: $selectedServer) { + ForEach(tracker.servers) { server in + HStack { + Text(server.name!).bold() + Spacer() HStack { - VStack(alignment: .leading) { - Text(server.name!).bold() - Image(systemName: "person.fill").opacity(0.3) - Text("\(server.users)").font(.system(size: 12)) - Text(server.description!).foregroundColor(.secondary).font(.system(size: 14)) - } - .padding(EdgeInsets(top: 15, leading: 18, bottom: 15, trailing: 18)) - Spacer() + Text("\(server.users)").font(.system(size: 12)).bold().opacity(0.3) + // Image(systemName: "person.fill").font(.system(size: 12)).opacity(0.3) } - .background(.white) - .cornerRadius(16) - .padding(EdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) - .frame(minWidth: 0, maxWidth: .infinity) + .padding(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10)) + .background(Color(white: 0.94)) + .cornerRadius(20.0) } +// .contentShape(Rectangle()) + .listRowSeparator(.hidden) + .tag(server) + // .padding(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 0)) +// .onTapGesture { +// self.selectedServer = server +// } } - .padding() } - .background(Color(white: 0.9)) -// .toolbar { -// ToolbarItem(placement: .navigationBarTrailing) { -// EditButton() -// } -// ToolbarItem { -// Button(action: addItem) { -// Label("Add Item", systemImage: "plus") -// } -// } -// } + .listStyle(.plain) + .frame(maxWidth: .infinity) .task { tracker.fetch() } - } - - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) + .sheet(item: $selectedServer) { item in + VStack(alignment: .leading) { + Text(item.name!).bold().dynamicTypeSize(.xxLarge).padding(EdgeInsets(top: 0, leading: 0, bottom: 8.0, trailing: 0)) + Text(item.description!).opacity(0.4).dynamicTypeSize(.xLarge).padding(EdgeInsets(top: 0, leading: 0, bottom: 8.0, trailing: 0)) + Text(item.address).opacity(0.2).dynamicTypeSize(.medium) + Spacer() + HStack(alignment: .center) { + Button("Connect") { + print("WHAT", "HELLO", selectedServer!.address) + HotlineClient.shared.connect(to: selectedServer!) +// client = HotlineClient(server: selectedServer) + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(.white) + .background(Color(.black)) + .cornerRadius(8.0) + } } + .padding(EdgeInsets(top: 28.0, leading: 24.0, bottom: 24.0, trailing: 24.0)) + .presentationDetents([.fraction(0.4)]) + .presentationDragIndicator(.visible) } +// List(tracker.servers) { server in +// HStack { +// Text(server.name!).bold() +// Spacer() +// HStack { +// Text("\(server.users)").font(.system(size: 12)).bold().opacity(0.3) +//// Image(systemName: "person.fill").font(.system(size: 12)).opacity(0.3) +// } +// .padding(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10)) +// .background(Color(white: 0.94)) +// .cornerRadius(20.0) +// } +// .contentShape(Rectangle()) +// .listRowSeparator(.hidden) +//// .padding(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 0)) +// .onTapGesture { +// self.selectedServer = server +// } +// } +// .listStyle(.plain) +// .frame(maxWidth: .infinity) +// .task { +// tracker.fetch() +// } +// .sheet(item: $selectedServer) { item in +// VStack(alignment: .leading) { +// Text(item.name!).bold().dynamicTypeSize(.xxLarge).padding(EdgeInsets(top: 0, leading: 0, bottom: 8.0, trailing: 0)) +// Text(item.description!).opacity(0.4).dynamicTypeSize(.xLarge) +// Spacer() +// HStack(alignment: .center) { +// Button("Connect") { +// print("WHAT") +// } +// .bold() +// .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) +// .frame(maxWidth: .infinity) +// .foregroundColor(.white) +// .background(Color(.black)) +// .cornerRadius(8.0) +// } +// } +// .padding(EdgeInsets(top: 28.0, leading: 24.0, bottom: 24.0, trailing: 24.0)) +// .presentationDetents([.fraction(0.3)]) +// .presentationDragIndicator(.visible) +// } } } #Preview { TrackerView() - .modelContainer(for: Item.self, inMemory: true) + // .modelContainer(for: Item.self, inMemory: true) } diff --git a/Hotline/Utility/DataExtensions.swift b/Hotline/Utility/DataExtensions.swift index 9530416..3f7101b 100644 --- a/Hotline/Utility/DataExtensions.swift +++ b/Hotline/Utility/DataExtensions.swift @@ -29,26 +29,36 @@ extension Data { return (endianness == .big) ? value.bigEndian : value.littleEndian } - func read<T: FixedWidthInteger>(type: T.Type, at offset: Int) -> T? { - guard offset >= 0, offset + MemoryLayout<T>.size <= self.count else { - return nil // Ensure the offset is within the Data's range - } - - return self.withUnsafeBytes { rawBufferPointer in - let pointer = rawBufferPointer.baseAddress! - .advanced(by: offset) - .assumingMemoryBound(to: T.self) - -// switch endianness { -// case .big: - return pointer.pointee.bigEndian -// case .little: -// return pointer.pointee.littleEndian -// } + func readData(at offset: Int, length: Int) -> Data? { + guard offset >= 0, offset + length <= self.count else { + return nil } + return self[offset..<(offset + length)] } func readString(at offset: Int, length: Int, encoding: String.Encoding) -> String? { return String(data: self[offset..<(offset + length)], encoding: encoding) } + + + mutating func appendUInt8(_ value: UInt8, endianness: Endianness = .big) { + var val = endianness == .big ? value.bigEndian : value.littleEndian + append(&val, count: MemoryLayout<UInt8>.size) + } + + mutating func appendUInt16(_ value: UInt16, endianness: Endianness = .big) { + var val = endianness == .big ? value.bigEndian : value.littleEndian + Swift.withUnsafeBytes(of: &val) { buffer in + append(buffer.bindMemory(to: UInt8.self)) + } +// append(&val, count: MemoryLayout<UInt16>.size) + } + + mutating func appendUInt32(_ value: UInt32, endianness: Endianness = .big) { + var val = endianness == .big ? value.bigEndian : value.littleEndian + Swift.withUnsafeBytes(of: &val) { buffer in + append(buffer.bindMemory(to: UInt8.self)) + } +// append(&val, count: MemoryLayout<UInt32>.size) + } } |