diff options
| author | Dustin Mierau <dustin@mierau.me> | 2023-11-27 12:39:11 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2023-11-27 12:39:11 -0800 |
| commit | de5ee043826bce23906bfcb351a2702e1aa88270 (patch) | |
| tree | 39c945bf3958d2e295a66596d1bac53ceb4c54d6 /Hotline | |
Getting started
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Assets.xcassets/AccentColor.colorset/Contents.json | 11 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json | 13 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | Hotline/HotlineApp.swift | 25 | ||||
| -rw-r--r-- | Hotline/Item.swift | 18 | ||||
| -rw-r--r-- | Hotline/Network/HotlineTracker.swift | 264 | ||||
| -rw-r--r-- | Hotline/Preview Content/Preview Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | Hotline/TrackerView.swift | 69 | ||||
| -rw-r--r-- | Hotline/Utility/DataExtensions.swift | 54 |
9 files changed, 466 insertions, 0 deletions
diff --git a/Hotline/Assets.xcassets/AccentColor.colorset/Contents.json b/Hotline/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Hotline/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json b/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Contents.json b/Hotline/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Hotline/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/HotlineApp.swift b/Hotline/HotlineApp.swift new file mode 100644 index 0000000..f351e67 --- /dev/null +++ b/Hotline/HotlineApp.swift @@ -0,0 +1,25 @@ +import SwiftUI +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 body: some Scene { + WindowGroup { + TrackerView() + } + .modelContainer(sharedModelContainer) + } +} diff --git a/Hotline/Item.swift b/Hotline/Item.swift new file mode 100644 index 0000000..4fa6e0c --- /dev/null +++ b/Hotline/Item.swift @@ -0,0 +1,18 @@ +// +// 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/HotlineTracker.swift b/Hotline/Network/HotlineTracker.swift new file mode 100644 index 0000000..9b21ab2 --- /dev/null +++ b/Hotline/Network/HotlineTracker.swift @@ -0,0 +1,264 @@ +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 + case connected +} + +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' + 0x00, 0x01 // Version + ]) + + var connection: NWConnection? + + var bytes = Data() + var maxDataLength: Int = 0 + var serverCount: Int = 0 + + @Published var connectionStatus: HotlineTrackerStatus = .disconnected + @Published var servers: [HotlineServer] = [] + + init(address: String, callback: @escaping ([HotlineServer]) -> Void) { + self.serverAddress = NWEndpoint.Host(address) + self.callback = callback + } + + func fetch() { + self.reset() + self.connect() + } + + private func reset() { + self.maxDataLength = 0 + self.serverCount = 0 + } + + private func connect() { + self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) + self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in + switch newState { + case .ready: + print("READY TO SEND AND RECEIVE DATA") + self?.connectionStatus = .connected + self?.sendMagic() + case .cancelled: + print("CONNECTION CANCELLED") + self?.connectionStatus = .disconnected + case .failed(let err): + print("CONNECTION ERROR \(err)") + self?.connectionStatus = .disconnected + default: + print("CONNECTION OTHER THING") + } + } + + self.connectionStatus = .connecting + self.connection?.start(queue: .global()) + } + + private func disconnect() { + guard let c = connection else { + print("HotlineTracker: already disconnected") + return + } + + c.cancel() + self.connection = nil + } + + private func sendMagic() { + guard let c = connection else { + print("HotlineTracker: invalid connection to send magic.") + return + } + +// 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 { + print("HotlineTracker: sending magic failed \(err)") + return + } + + print("HotlineTracker: sent magic!") + + self?.receiveMagic() + }) + } + + private func receiveMagic() { + guard let c = connection else { + print("HotlineTracker: invalid connection to receive magic.") + return + } + + print("HotlineTracker: receiving...") + c.receive(minimumIncompleteLength: 6, maximumLength: 6) { [weak self] (data, context, isComplete, error) in + guard let self = self, let data = data else { + return + } + + if data.isEmpty || !data.elementsEqual(HotlineTracker.magicPacket) { + print("HotlineTracker: invalid magic response") + self.disconnect() + return + } +// if let data = data, !data.isEmpty { + print("HotlineTracker: received magic response!") +// } + + if let error = error { + print("HotlineTracker: receive error \(error)") + } + else { + self.receiveHeader() + } + } + } + + private func receiveHeader() { + guard let c = connection else { + print("HotlineTracker: invalid connection to receive header.") + return + } + + print("HotlineTracker: receiving...") + c.receive(minimumIncompleteLength: 8, maximumLength: 8) { [weak self] (data, context, isComplete, error) in + guard let self = self else { + return + } + + if let error = error { + print("HotlineTracker: receive error \(error)") + self.disconnect() + return + } + + 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)") + + self.serverCount = Int(data[4]) * 256 + Int(data[5]) + print("HotlineTracker: server count = \(self.serverCount)") + } + + if let error = error { + print("HotlineTracker: receive error \(error)") + } + else { + self.receiveListing() + } + } + } + + private func receiveListing() { + guard let c = connection else { + print("HotlineTracker: invalid connection to receive data.") + return + } + + print("HotlineTracker: receiving...") + c.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] (data, context, isComplete, error) in + guard let self = self else { + return + } + + if let data = data, !data.isEmpty { + print("HotlineTracker: received \(data.count) bytes") + self.bytes.append(contentsOf: data) + + if bytes.count >= maxDataLength { + print("HotlineTracker: done with data, should close. \(self.bytes.count) \(self.maxDataLength)") + self.disconnect() + self.parseListing() + return + } + } + + if let error = error { + print("HotlineTracker: receive error \(error)") + self.disconnect() + } + else { + print("HotlineTracker: not complete") + self.receiveListing() + } + } + } + + private func parseListing() { + // IP address (4 bytes) + // Port number (2 bytes) + // Number of users (2 bytes) + // Unused (2 bytes) + // Name size (1 byte) + // Name (name size) + // Description size (1 byte) + // Description (description size) + + var servers: [HotlineServer] = [] + + var cursor = 0 + for _ in 1...self.serverCount { + if self.bytes.count < cursor + 12 { + print("HotlineTracker: Data isn't long enough for next server") + break + } + + if + let ip_1 = self.bytes.readUInt8(at: cursor), + let ip_2 = self.bytes.readUInt8(at: cursor + 1), + let ip_3 = self.bytes.readUInt8(at: cursor + 2), + let ip_4 = self.bytes.readUInt8(at: cursor + 3), + let port = self.bytes.readUInt16(at: cursor + 4), + let userCount = self.bytes.readUInt16(at: cursor + 6), + let nameLengthByte = self.bytes.readUInt8(at: cursor + 10) { + + let nameLength = Int(nameLengthByte) + if let name = self.bytes.readString(at: cursor + 11, length: nameLength, encoding: .utf8) ?? self.bytes.readString(at: cursor + 11, length: nameLength, encoding: .ascii) { + if let descLengthByte = self.bytes.readUInt8(at: cursor + 11 + nameLength) { + let descLength = Int(descLengthByte) + if let desc = self.bytes.readString(at: cursor + 11 + nameLength + 1, length: descLength, encoding: .utf8) ?? self.bytes.readString(at: cursor + 11 + nameLength + 1, length: descLength, encoding: .ascii) { + let server = HotlineServer(address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount, name: name, description: desc) + + print("SERVER: \(server)") + + servers.append(server) + + cursor += 11 + nameLength + 1 + descLength + } + } + } + } + + print(cursor) + } + + DispatchQueue.main.async { + print("CALLING CALLBACK") + self.servers = servers + self.callback(servers) + } + + } +} diff --git a/Hotline/Preview Content/Preview Assets.xcassets/Contents.json b/Hotline/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Hotline/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/TrackerView.swift b/Hotline/TrackerView.swift new file mode 100644 index 0000000..04498b1 --- /dev/null +++ b/Hotline/TrackerView.swift @@ -0,0 +1,69 @@ +import SwiftUI +import SwiftData + +struct TrackerView: View { + @Environment(\.modelContext) private var modelContext + @Query private var items: [Item] + + @StateObject var tracker = HotlineTracker(address: "hltracker.com", callback: { s in + print("ALL DONE") + }) + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + ForEach(tracker.servers) { server in + 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() + } + .background(.white) + .cornerRadius(16) + .padding(EdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) + .frame(minWidth: 0, maxWidth: .infinity) + } + } + .padding() + } + .background(Color(white: 0.9)) +// .toolbar { +// ToolbarItem(placement: .navigationBarTrailing) { +// EditButton() +// } +// ToolbarItem { +// Button(action: addItem) { +// Label("Add Item", systemImage: "plus") +// } +// } +// } + .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]) + } + } + } +} + +#Preview { + TrackerView() + .modelContainer(for: Item.self, inMemory: true) +} diff --git a/Hotline/Utility/DataExtensions.swift b/Hotline/Utility/DataExtensions.swift new file mode 100644 index 0000000..9530416 --- /dev/null +++ b/Hotline/Utility/DataExtensions.swift @@ -0,0 +1,54 @@ +import Foundation + +enum Endianness { + case big + case little +} + +extension Data { + func readUInt8(at offset: Int) -> UInt8? { + guard offset >= 0, offset + MemoryLayout<UInt8>.size <= self.count else { + return nil + } + return self[offset] + } + + func readUInt16(at offset: Int, endianness: Endianness = .big) -> UInt16? { + guard offset >= 0, offset + MemoryLayout<UInt16>.size <= self.count else { + return nil + } + let value = self.subdata(in: offset..<(offset + MemoryLayout<UInt16>.size)).withUnsafeBytes { $0.load(as: UInt16.self) } + return (endianness == .big) ? value.bigEndian : value.littleEndian + } + + func readUInt32(at offset: Int, endianness: Endianness = .big) -> UInt32? { + guard offset >= 0, offset + MemoryLayout<UInt32>.size <= self.count else { + return nil + } + let value = self.subdata(in: offset..<(offset + MemoryLayout<UInt32>.size)).withUnsafeBytes { $0.load(as: UInt32.self) } + 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 readString(at offset: Int, length: Int, encoding: String.Encoding) -> String? { + return String(data: self[offset..<(offset + length)], encoding: encoding) + } +} |