From 471546236b8991f61d26c0e3aa8ee48b83b983af Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 4 Dec 2023 09:12:58 -0800 Subject: Starting to hash out a server view and tabs. Added research pdfs. etc. --- Hotline.xcodeproj/project.pbxproj | 48 +++- Hotline/Hotline/HotlineClient.swift | 437 +++++++++++++++++++++++++++++ Hotline/Hotline/HotlineClientModel.swift | 10 + Hotline/Hotline/HotlineProtocol.swift | 372 ++++++++++++++++++++++++ Hotline/Hotline/HotlineTrackerClient.swift | 277 ++++++++++++++++++ Hotline/HotlineApp.swift | 8 +- Hotline/Network/HotlineClient.swift | 432 ---------------------------- Hotline/Network/HotlineProtocol.swift | 388 ------------------------- Hotline/Network/HotlineTracker.swift | 260 ----------------- Hotline/TrackerView.swift | 100 ------- Hotline/Views/AgreementView.swift | 35 +++ Hotline/Views/ChatView.swift | 63 +++++ Hotline/Views/HotlineView.swift | 20 ++ Hotline/Views/ServerView.swift | 62 ++++ Hotline/Views/TrackerServerView.swift | 47 ++++ Hotline/Views/TrackerView.swift | 73 +++++ Research/hotline 2 constants.pdf | Bin 0 -> 171784 bytes Research/hotline access bits.pdf | Bin 0 -> 83110 bytes Research/hotline date params.pdf | Bin 0 -> 993496 bytes Research/hotline path params.pdf | Bin 0 -> 170734 bytes Research/hotline protocol good.pdf | Bin 0 -> 430429 bytes Research/hotline server protocol.pdf | Bin 0 -> 119131 bytes Research/hotline trackers.pdf | Bin 0 -> 85875 bytes Research/hotline transactions.pdf | Bin 0 -> 79382 bytes 24 files changed, 1443 insertions(+), 1189 deletions(-) create mode 100644 Hotline/Hotline/HotlineClient.swift create mode 100644 Hotline/Hotline/HotlineClientModel.swift create mode 100644 Hotline/Hotline/HotlineProtocol.swift create mode 100644 Hotline/Hotline/HotlineTrackerClient.swift delete mode 100644 Hotline/Network/HotlineClient.swift delete mode 100644 Hotline/Network/HotlineProtocol.swift delete mode 100644 Hotline/Network/HotlineTracker.swift delete mode 100644 Hotline/TrackerView.swift create mode 100644 Hotline/Views/AgreementView.swift create mode 100644 Hotline/Views/ChatView.swift create mode 100644 Hotline/Views/HotlineView.swift create mode 100644 Hotline/Views/ServerView.swift create mode 100644 Hotline/Views/TrackerServerView.swift create mode 100644 Hotline/Views/TrackerView.swift create mode 100644 Research/hotline 2 constants.pdf create mode 100644 Research/hotline access bits.pdf create mode 100644 Research/hotline date params.pdf create mode 100644 Research/hotline path params.pdf create mode 100644 Research/hotline protocol good.pdf create mode 100644 Research/hotline server protocol.pdf create mode 100644 Research/hotline trackers.pdf create mode 100644 Research/hotline transactions.pdf diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 05dc8fd..0bd761f 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -7,25 +7,37 @@ objects = { /* Begin PBXBuildFile section */ + DA43205C2B1D003B00FC8843 /* HotlineClientModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA43205B2B1D003B00FC8843 /* HotlineClientModel.swift */; }; + DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA43205D2B1D615600FC8843 /* ServerView.swift */; }; DA4F2BF62B1668A700D8ADDC /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF52B1668A700D8ADDC /* HotlineClient.swift */; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; + DA4F2BFB2B1A518500D8ADDC /* AgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BFA2B1A518500D8ADDC /* AgreementView.swift */; }; + DA4F2BFD2B1A52EF00D8ADDC /* TrackerServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BFC2B1A52EF00D8ADDC /* TrackerServerView.swift */; }; + DA4F2BFF2B1A54FF00D8ADDC /* HotlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BFE2B1A54FF00D8ADDC /* HotlineView.swift */; }; + DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; }; DA9CAFBB2B126D5700CDA197 /* HotlineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBA2B126D5700CDA197 /* HotlineApp.swift */; }; DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */; }; DA9CAFC12B126D5800CDA197 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC02B126D5800CDA197 /* Assets.xcassets */; }; DA9CAFC42B126D5800CDA197 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */; }; - DA9CAFCB2B126E3300CDA197 /* HotlineTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFCA2B126E3300CDA197 /* HotlineTracker.swift */; }; + DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */; }; DABFCC292B1530DC009F40D2 /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* DataExtensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + DA43205B2B1D003B00FC8843 /* HotlineClientModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientModel.swift; sourceTree = ""; }; + DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; DA4F2BF52B1668A700D8ADDC /* HotlineClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClient.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; + DA4F2BFA2B1A518500D8ADDC /* AgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementView.swift; sourceTree = ""; }; + DA4F2BFC2B1A52EF00D8ADDC /* TrackerServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerServerView.swift; sourceTree = ""; }; + DA4F2BFE2B1A54FF00D8ADDC /* HotlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineView.swift; sourceTree = ""; }; + DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; DA9CAFB72B126D5700CDA197 /* Hotline.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hotline.app; sourceTree = BUILT_PRODUCTS_DIR; }; DA9CAFBA2B126D5700CDA197 /* HotlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineApp.swift; sourceTree = ""; }; DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerView.swift; sourceTree = ""; }; DA9CAFC02B126D5800CDA197 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - DA9CAFCA2B126E3300CDA197 /* HotlineTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTracker.swift; sourceTree = ""; }; + DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTrackerClient.swift; sourceTree = ""; }; DABFCC282B1530DC009F40D2 /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -40,6 +52,19 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + DA4F2BF92B1A516F00D8ADDC /* Views */ = { + isa = PBXGroup; + children = ( + DA4F2BFA2B1A518500D8ADDC /* AgreementView.swift */, + DA4F2BFE2B1A54FF00D8ADDC /* HotlineView.swift */, + DA43205D2B1D615600FC8843 /* ServerView.swift */, + DA4F2C002B1A558E00D8ADDC /* ChatView.swift */, + DA4F2BFC2B1A52EF00D8ADDC /* TrackerServerView.swift */, + DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */, + ); + path = Views; + sourceTree = ""; + }; DA9CAFAE2B126D5700CDA197 = { isa = PBXGroup; children = ( @@ -60,8 +85,8 @@ isa = PBXGroup; children = ( DA9CAFBA2B126D5700CDA197 /* HotlineApp.swift */, - DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */, - DABFCC262B1530AE009F40D2 /* Network */, + DA4F2BF92B1A516F00D8ADDC /* Views */, + DABFCC262B1530AE009F40D2 /* Hotline */, DABFCC272B1530BE009F40D2 /* Utility */, DA9CAFC02B126D5800CDA197 /* Assets.xcassets */, DA9CAFC22B126D5800CDA197 /* Preview Content */, @@ -77,14 +102,15 @@ path = "Preview Content"; sourceTree = ""; }; - DABFCC262B1530AE009F40D2 /* Network */ = { + DABFCC262B1530AE009F40D2 /* Hotline */ = { isa = PBXGroup; children = ( DA4F2BF52B1668A700D8ADDC /* HotlineClient.swift */, - DA9CAFCA2B126E3300CDA197 /* HotlineTracker.swift */, + DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, + DA43205B2B1D003B00FC8843 /* HotlineClientModel.swift */, ); - path = Network; + path = Hotline; sourceTree = ""; }; DABFCC272B1530BE009F40D2 /* Utility */ = { @@ -165,12 +191,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA43205C2B1D003B00FC8843 /* HotlineClientModel.swift in Sources */, + DA4F2BFB2B1A518500D8ADDC /* AgreementView.swift in Sources */, + DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, + DA4F2BFD2B1A52EF00D8ADDC /* TrackerServerView.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, - DA9CAFCB2B126E3300CDA197 /* HotlineTracker.swift in Sources */, + DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* HotlineApp.swift in Sources */, + DA4F2BFF2B1A54FF00D8ADDC /* HotlineView.swift in Sources */, DA4F2BF62B1668A700D8ADDC /* HotlineClient.swift in Sources */, DABFCC292B1530DC009F40D2 /* DataExtensions.swift in Sources */, DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */, + DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift new file mode 100644 index 0000000..b5ea1d9 --- /dev/null +++ b/Hotline/Hotline/HotlineClient.swift @@ -0,0 +1,437 @@ +import Foundation +import Network + +enum HotlineClientStatus: Int { + case disconnected + case connecting + case connected + case loggingIn + case loggedIn +} + +struct HotlineChat { + let message: String + let userID: UInt16? + let isTopic: Bool +} + +@Observable +class HotlineClient { +// 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 + ]) + + var connectionStatus: HotlineClientStatus = .disconnected + var agreement: String? + var users: [UInt16:HotlineUser] = [:] + var userList: [UInt16] = [] + var chatMessages: [HotlineChat] = [] + + var userName: String = "bolt" + var userIconID: UInt16 = 128 + var serverVersion: UInt16 = 151 + var server: HotlineServer? + + @ObservationIgnored private var connection: NWConnection? + @ObservationIgnored private var transactionLog: [UInt32:HotlineTransactionType] = [:] + + init() { + + } + + // MARK: - + + func connect(to server: HotlineServer) { + self.server = server + + let serverAddress = NWEndpoint.Host(server.address) + let serverPort = NWEndpoint.Port(rawValue: server.port)! + + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveInterval = 30 + let connectionParameters: NWParameters + connectionParameters = NWParameters(tls: nil, tcp: tcpOptions) + + self.connection = NWConnection(host: serverAddress, port: serverPort, using: connectionParameters) + 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() { + self.connection?.cancel() + self.connection = nil + + DispatchQueue.main.async { + self.connectionStatus = .disconnected + } + } + + // MARK: - + + private func sendTransaction(_ t: HotlineTransaction, autodisconnect disconnectOnError: Bool = true, callback: (() -> Void)? = nil) { + guard let c = connection else { + return + } + + print("HotlineClient => \(t.id) \(t.type)") + + self.transactionLog[t.id] = t.type + + c.send(content: t.encoded(), completion: .contentProcessed { [weak self] (error) in + if disconnectOnError, error != nil { + self?.disconnect() + return + } + + callback?() + }) + } + + private func receiveTransaction() { + guard let c = connection else { + print("HotlineClient: no connection for transaction.") + return + } + + print("HotlineClient ⏳") + c.receive(minimumIncompleteLength: HotlineTransaction.headerSize, maximumLength: HotlineTransaction.headerSize) { [weak self] (headerData, context, isComplete, error) in + guard let self = self else { + return + } + + if let error = error { + print("HotlineClient: transaction error \(error)") + self.disconnect() + return + } + + guard let headerData = headerData, !headerData.isEmpty else { + self.receiveTransaction() + return + } + +// print("HotlineClient: received \(headerData.count) header bytes") + + if var transaction = self.parseTransaction(data: headerData) { + // Receive additional data if the transaction has data attached to it. + print("DATA SIZE: \(transaction.dataSize)") + if transaction.dataSize > 0 { + c.receive(minimumIncompleteLength: Int(transaction.dataSize), maximumLength: Int(transaction.dataSize)) { [weak self] (fieldData, context, isComplete, error) in + guard let self = self else { + return + } + + guard let fieldData = fieldData, !fieldData.isEmpty else { + print("HotlineClient: transaction field data is empty!") + self.disconnect() + return + } + + let fieldCount = fieldData.readUInt16(at: 0)! + + if fieldCount > 0 { + var dataCursor = 2 + for _ in 0.. 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) { + + if let transactionType = HotlineTransactionType(rawValue: type) { + return HotlineTransaction(type: transactionType, flags: flags, isReply: isReply, id: id, errorCode: errorCode, totalSize: transactionSize, dataSize: dataSize) + } + else { + print("HotlineClient: Unknown type \(type) parsing with \(dataSize) bytes") + } + } + + return nil + } + + // MARK: - Messages + + 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 handshake failed \(err)") + 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 🤝") + self.sendLogin() { [weak self] in + self?.sendSetClientUserInfo() + self?.sendGetUserList() + } + self.receiveTransaction() + } + }) + } + + func sendLogin(callback: (() -> Void)? = nil) { + DispatchQueue.main.async { + self.connectionStatus = .loggingIn + } + + var t = HotlineTransaction(type: .login) + t.setFieldEncodedString(type: .userLogin, val: "") + t.setFieldEncodedString(type: .userPassword, val: "") + t.setFieldUInt16(type: .userIconID, val: self.userIconID) + t.setFieldString(type: .userName, val: self.userName) + t.setFieldUInt32(type: .versionNumber, val: 123) + + self.sendTransaction(t) { [weak self] in + DispatchQueue.main.async { + self?.connectionStatus = .loggedIn + } + + callback?() + } + } + + func sendSetClientUserInfo(callback: (() -> Void)? = nil) { + var t = HotlineTransaction(type: .setClientUserInfo) + t.setFieldString(type: .userName, val: self.userName) + t.setFieldUInt16(type: .userIconID, val: self.userIconID) + + self.sendTransaction(t, callback: callback) + } + + func sendAgree(callback: (() -> Void)? = nil) { + var t = HotlineTransaction(type: .agreed) + t.setFieldString(type: .userName, val: self.userName) + t.setFieldUInt16(type: .userIconID, val: self.userIconID) + t.setFieldUInt32(type: .options, val: 0) + self.sendTransaction(t, callback: callback) + } + + func sendChat(message: String, callback: (() -> Void)? = nil) { + var t = HotlineTransaction(type: .sendChat) + t.setFieldString(type: .data, val: message) + self.sendTransaction(t, callback: callback) + } + + func sendGetUserList(callback: (() -> Void)? = nil) { + let t = HotlineTransaction(type: .getUserNameList) + self.sendTransaction(t, callback: callback) + } + + // MARK: - Incoming + + private func processReply(_ transaction: HotlineTransaction) { + guard transaction.errorCode == 0 else { + if let errorParam = transaction.getField(type: .errorText), let errorText = errorParam.getString() { + print("HotlineClient 😵 \(transaction.errorCode): \(errorText)") + } + else { + print("HotlineClient 😵 \(transaction.errorCode)") + } + return + } + + guard let repliedTransactionType = self.transactionLog[transaction.id] else { + return + } + + print("HotlineClient reply in response to \(repliedTransactionType)") + + switch(repliedTransactionType) { + case .login: + print("GOT REPLY TO LOGIN!") + + if + let serverVersionField = transaction.getField(type: .versionNumber), + let serverVersion = serverVersionField.getUInt16() { + self.serverVersion = serverVersion + print("SERVER VERSION: \(serverVersion)") + } + case .getUserNameList: + print("GOT USER LIST") + var newUsers: [UInt16:HotlineUser] = [:] + var newUserList: [UInt16] = [] + for u in transaction.getFieldList(type: .userNameWithInfo) { + let user = u.getUser() + newUsers[user.id] = user + newUserList.append(user.id) + } + DispatchQueue.main.async { + self.users = newUsers + self.userList = newUserList + + print("HotlineClient got users:\n") + print("\(self.userList)\n\n") + } + default: + break + } + } + + private func processTransaction(_ transaction: HotlineTransaction) { + if transaction.type == .reply { + print("HotlineClient <= \(transaction.type) to \(transaction.id):") + print(transaction) + } + else { + print("HotlineClient <= \(transaction.type)") + } + + switch(transaction.type) { + case .reply: + self.processReply(transaction) +// print("HotlineClient: Received reply transaction: \(transaction)") + + case .chatMessage: + if + let chatTextParam = transaction.getField(type: .data), + let chatText = chatTextParam.getString(), + let userNameParam = transaction.getField(type: .userName), + let userName = userNameParam.getString(), + let userIDParam = transaction.getField(type: .userID), + let userID = userIDParam.getUInt16() { + print("HotlineClient: \(userName):\(userID): \(chatText)") + DispatchQueue.main.async { + self.chatMessages.append(HotlineChat(message: chatText, userID: userID, isTopic: false)) + } + } + case .notifyOfUserChange: +// print("HotlineClient: user changed") + if let p = transaction.getField(type: .userName), + let userName = p.getString() { + print("HotlineClient: User changed \(userName)") + } + case .disconnectMessage: + print("HotlineClient ❌") + self.disconnect() + case .showAgreement: + if let noAgreementField = transaction.getField(type: .noServerAgreement) { + print("NO AGREEMENT?") + } + if let agreementParam = transaction.getField(type: .data) { + if let agreementText = agreementParam.getString() { + print("\n\n--------------------------\n") + print(agreementText) + print("\n--------------------------\n\n") + DispatchQueue.main.async { + self.agreement = agreementText + } +// self.sendAgree() { +// self.sendGetUserList() +// } + } + } + case .userAccess: + print("") + default: + print("HotlineClient: UNKNOWN transaction \(transaction.type) with \(transaction.fields.count) parameters") + print(transaction.fields) + } + } +} diff --git a/Hotline/Hotline/HotlineClientModel.swift b/Hotline/Hotline/HotlineClientModel.swift new file mode 100644 index 0000000..a45a5e2 --- /dev/null +++ b/Hotline/Hotline/HotlineClientModel.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@Observable +class HotlineClientModel { + var status: HotlineClientStatus = .disconnected + var server: HotlineServer? + var userlist: [HotlineUser]? + var chat: [(HotlineUser, String)]? + var agreement: String? +} diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift new file mode 100644 index 0000000..200ac47 --- /dev/null +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -0,0 +1,372 @@ +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 HotlineUser: Identifiable, Hashable { + let id: UInt16 + let iconID: UInt16 + let status: UInt16 + let name: String + + var isAdmin: Bool { + return ((self.status & 0x0002) != 0) + } + + var isIdle: Bool { + return ((self.status & 0x0001) != 0) + } + + static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool { + return lhs.id == rhs.id + } + + init(id: UInt16, iconID: UInt16, status: UInt16, name: String) { + self.id = id + self.iconID = iconID + self.status = status + self.name = name + } + + init(from data: Data) { + self.id = data.readUInt16(at: 0)! + self.iconID = data.readUInt16(at: 2)! + self.status = data.readUInt16(at: 4)! + + let userNameLength = Int(data.readUInt16(at: 6)!) + self.name = data.readString(at: 8, length: userNameLength, encoding: .ascii)! + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + + func encoded() -> Data { + var data = Data() + self.encode(to: &data) + return data + } + + func encode(to data: inout Data) { + data.appendUInt16(self.id) + data.appendUInt16(self.iconID) + data.appendUInt16(self.status) + + let userNameData = name.data(using: .ascii, allowLossyConversion: true)! + + data.appendUInt16(UInt16(userNameData.count)) + data.append(userNameData) + } +} + +struct HotlineTransactionField { + let type: HotlineTransactionFieldType + let dataSize: UInt16 + let data: Data + + init(type: HotlineTransactionFieldType, dataSize: UInt16, data: Data) { + self.type = type + self.dataSize = dataSize + self.data = data + } + + init(type: HotlineTransactionFieldType, val: UInt8) { + self.init(type: type, dataSize: UInt16(MemoryLayout.size), data: Data(val)) + } + + init(type: HotlineTransactionFieldType, val: UInt16) { + self.init(type: type, dataSize: UInt16(MemoryLayout.size), data: Data(val)) + } + + init(type: HotlineTransactionFieldType, val: UInt32) { + self.init(type: type, dataSize: UInt16(MemoryLayout.size), data: Data(val)) + } + + init(type: HotlineTransactionFieldType, string: String, encoding: String.Encoding = .ascii, encrypt: Bool = false) { + var stringInput = string + + if encrypt { + stringInput = String(string.utf8.map { char in + Character(UnicodeScalar(0xFF - char)) + }) + } + + var stringData: Data? + stringData = stringInput.data(using: encoding, allowLossyConversion: true) + if stringData == nil { + stringData = Data() + } + + self.init(type: type, dataSize: UInt16(stringData!.count), data: stringData!) + } + + init(type: HotlineTransactionFieldType, string: String, encrypt: Bool) { + self.init(type: type, string: string, encoding: .ascii, encrypt: encrypt) + } + + func getUInt8() -> UInt8? { + return self.data.readUInt8(at: 0) + } + + func getUInt16() -> UInt16? { + return self.data.readUInt16(at: 0) + } + + func getUInt32() -> UInt32? { + return self.data.readUInt32(at: 0) + } + + func getInteger() -> Int? { + switch(self.data.count) { + case 1: + if let val = self.getUInt8() { + return Int(val) + } + case 2: + if let val = self.getUInt16() { + return Int(val) + } + case 4: + if let val = self.getUInt32() { + return Int(val) + } + default: + break + } + + return nil + } + + func getString(encoding: String.Encoding = .ascii) -> String? { + return String(data: self.data, encoding: encoding) + } + + func getUser() -> HotlineUser { + return HotlineUser(from: self.data) + } +} + +struct HotlineTransaction { + static let headerSize = 20 + static var sequenceID: UInt32 = 1 + + static func nextID() -> UInt32 { + HotlineTransaction.sequenceID += 1 + return HotlineTransaction.sequenceID + } + + var flags: UInt8 = 0 + var isReply: UInt8 = 0 + var type: HotlineTransactionType + var id: UInt32 = HotlineTransaction.nextID() + var errorCode: UInt32 = 0 + var totalSize: UInt32 = UInt32(HotlineTransaction.headerSize) + var dataSize: UInt32 = 0 + + var fields: [HotlineTransactionField] = [] + + init(type: HotlineTransactionType) { + self.type = type + } + + init(type: HotlineTransactionType, flags: UInt8, isReply: UInt8, id: UInt32, errorCode: UInt32, totalSize: UInt32, dataSize: UInt32) { + self.type = type + self.flags = flags + self.isReply = isReply + self.id = id + self.errorCode = errorCode + self.totalSize = totalSize + self.dataSize = dataSize + } + + mutating func setFieldUInt8(type: HotlineTransactionFieldType, val: UInt8) { + self.fields.append(HotlineTransactionField(type: type, val: val)) + } + + mutating func setFieldUInt16(type: HotlineTransactionFieldType, val: UInt16) { + self.fields.append(HotlineTransactionField(type: type, val: val)) + } + + mutating func setFieldUInt32(type: HotlineTransactionFieldType, val: UInt32) { + self.fields.append(HotlineTransactionField(type: type, val: val)) + } + + mutating func setFieldEncodedString(type: HotlineTransactionFieldType, val: String) { + self.fields.append(HotlineTransactionField(type: type, string: val, encrypt: true)) + } + + mutating func setFieldString(type: HotlineTransactionFieldType, val: String) { + self.fields.append(HotlineTransactionField(type: type, string: val)) + } + + func getField(type: HotlineTransactionFieldType) -> HotlineTransactionField? { + return self.fields.first { p in + p.type == type + } + } + + func getFieldList(type: HotlineTransactionFieldType) -> [HotlineTransactionField] { + return self.fields.filter { p in + p.type == type + } + } + + func encoded() -> Data { + var data = Data() + self.encode(to: &data) + return data + } + + 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) + + if self.fields.count > 0 { + var fieldData = Data() + fieldData.appendUInt16(UInt16(self.fields.count)) + for f in self.fields { + fieldData.appendUInt16(f.type.rawValue) + fieldData.appendUInt16(f.dataSize) + fieldData.append(f.data) + } + + data.appendUInt32(UInt32(fieldData.count)) + data.appendUInt32(UInt32(fieldData.count)) + data.append(fieldData) + } + else { + data.appendUInt32(2) + data.appendUInt32(2) + data.appendUInt16(0) + } + } +} + +enum HotlineTransactionFieldType: UInt16 { + case errorText = 100 // String + case data = 101 // String + case userName = 102 // String + case userID = 103 // Integer + case userIconID = 104 // Integer + case userLogin = 105 // Encoded string + case userPassword = 106 // Encoded string + case referenceNumber = 107 // Integer + case transferSize = 108 // Integer + case chatOptions = 109 // Integer + case userAccess = 110 // 64-bit integer? + case userAlias = 111 // ??? + case userFlags = 112 // Integer + case options = 113 // 32-bit integer? + case chatID = 114 // Integer + case chatSubject = 115 // String + case waitingCount = 116 // Integer + case serverAgreement = 150 // ??? + case serverBanner = 151 // Data? + case serverBannerType = 152 // Integer + case serverBannerURL = 153 // String + case noServerAgreement = 154 // Integer + case versionNumber = 160 // Integer + case communityBannerID = 161 // Integer + case serverName = 162 // String + // TODO: Add file field types + case quotingMessage = 214 // String? + case automaticResponse = 215 // String + case folderItemCount = 220 // Integer + case userNameWithInfo = 300 // Data { user id: 2, icon id: 2, user flags: 2, user name size: 2, user name: size } + case newsCategoryGUID = 319 // Data? + case newsCategoryListData = 320 // Data { type: 1 (1 = folder, 10 = category, 255 = other), category name: rest } + case newsArticleListData = 321 // Data + case newsCategoryName = 322 // String + case newsCategoryListData15 = 323 // Data + case newsPath = 325 // Data + case newsArticleID = 326 // Integer + case newsArticleDataFlavor = 327 // String + case newsArticleTitle = 328 // String + case newsArticlePoster = 329 // String + case newsArticleDate = 330 // Data { year: 2, ms: 2, secs: 4 } + case newsArticlePrevious = 331 // Integer + case newsArticleNext = 332 // Integer + case newsArticleData = 333 // Data + case newsArticleFlags = 334 // Integer + case newsArticleParentArticle = 335 // Integer + case newsArticleFirstChildArticle = 336 // Integer + case newsArticleRecursiveDelete = 337 // Integer +} + +enum HotlineTransactionType: UInt16 { + case reply = 0 + case error = 100 + case getMessages = 101 + case newMessage = 102 // Server + case oldPostNews = 103 + case serverMessage = 104 // Server + case sendChat = 105 + case chatMessage = 106 // Server + case login = 107 + case sendInstantMessage = 108 + case showAgreement = 109 // Server + case disconnectUser = 110 + case disconnectMessage = 111 // Server + case inviteToNewChat = 112 + case inviteToChat = 113 // Server + case rejectChatInvite = 114 + case joinChat = 115 + case leaveChat = 116 + case notifyChatOfUserChange = 117 // Server + case notifyChatOfUserDelete = 118 // Server + case notifyChatSubject = 119 // Server + case setChatSubject = 120 + case agreed = 121 + case serverBanner = 122 // Server + 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 // Server + case downloadBanner = 212 + case uploadFolder = 213 + case getUserNameList = 300 + case notifyOfUserChange = 301 // Server + case notifyOfUserDelete = 302 // Server + case getClientInfoText = 303 + case setClientUserInfo = 304 + case newUser = 350 + case deleteUser = 351 + case getUser = 352 + case setUser = 353 + case userAccess = 354 // Server + case userBroadcast = 355 // Client & Server + case getNewsCategoryNameList = 370 + case getNewsArticleNameList = 371 + case deleteNewsItem = 380 + case newNewsFolder = 381 + case newNewsCategory = 382 + case getNewsArticleData = 400 + case postNewsArticle = 410 + case deleteNewsArticle = 411 + case connectionKeepAlive = 500 +} + diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift new file mode 100644 index 0000000..fd4e97b --- /dev/null +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -0,0 +1,277 @@ +import Foundation +import Network + +enum HotlineTrackerStatus: Int { + case disconnected + case connecting + case connected +} + +struct HotlineTracker: Identifiable { + var address: String + var port: UInt16 + var servers: [HotlineServer] = [] + var expanded: Bool = false + + var id: String { get { return self.address } } + + init(_ address: String, port: UInt16 = 5498) { + self.address = address + self.port = port + } +} + +@Observable +class HotlineTrackerClient { + static let magicPacket = Data([ + 0x48, 0x54, 0x52, 0x4B, // 'HTRK' + 0x00, 0x01 // Version + ]) + + @ObservationIgnored private let serverAddress: NWEndpoint.Host + @ObservationIgnored private let serverPort: NWEndpoint.Port + @ObservationIgnored private var connection: NWConnection? + @ObservationIgnored private var bytes = Data() + @ObservationIgnored private var maxDataLength: Int = 0 + @ObservationIgnored private var serverCount: Int = 0 + + var tracker: HotlineTracker + var connectionStatus: HotlineTrackerStatus = .disconnected + var servers: [HotlineServer] = [] + + init(tracker: HotlineTracker) { + self.tracker = tracker + self.serverAddress = NWEndpoint.Host(tracker.address) + self.serverPort = NWEndpoint.Port(rawValue: tracker.port)! + } + + 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") + DispatchQueue.main.async { + self?.connectionStatus = .connected + } + self?.sendMagic() + case .cancelled: + print("CONNECTION CANCELLED") + DispatchQueue.main.async { + self?.connectionStatus = .disconnected + } + case .failed(let err): + print("CONNECTION ERROR \(err)") + DispatchQueue.main.async { + self?.connectionStatus = .disconnected + } + default: + print("CONNECTION OTHER THING") + } + } + + DispatchQueue.main.async { + 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: HotlineTrackerClient.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(HotlineTrackerClient.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 foundServers: [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)") + + foundServers.append(server) + + cursor += 11 + nameLength + 1 + descLength + } + } + } + } + + print(cursor) + } + + DispatchQueue.main.async { + self.tracker.servers = foundServers +// print("CALLING CALLBACK") + self.servers = foundServers + } + + } +} diff --git a/Hotline/HotlineApp.swift b/Hotline/HotlineApp.swift index da49fd0..238ca48 100644 --- a/Hotline/HotlineApp.swift +++ b/Hotline/HotlineApp.swift @@ -16,9 +16,15 @@ struct HotlineApp: App { // } // }() + @State private var hotline = HotlineClient() + @State private var tracker = HotlineTrackerClient(tracker: HotlineTracker("hltracker.com")) + var body: some Scene { + WindowGroup { - TrackerView() + HotlineView() + .environment(hotline) + .environment(tracker) } // .modelContainer(sharedModelContainer) } diff --git a/Hotline/Network/HotlineClient.swift b/Hotline/Network/HotlineClient.swift deleted file mode 100644 index b5092a0..0000000 --- a/Hotline/Network/HotlineClient.swift +++ /dev/null @@ -1,432 +0,0 @@ -import Foundation -import Network - -enum HotlineClientStatus: Int { - case disconnected - case connecting - case connected - case loggingIn - case loggedIn -} - -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 - @Published var agreement: String? - @Published var userList: [HotlineUser] = [] - @Published var chatMessages: [String] = [] - - var userName: String = "bolt" - var userIconID: UInt16 = 128 - var serverVersion: UInt16 = 151 - var server: HotlineServer? - var connection: NWConnection? - - private var transactionLog: [UInt32:HotlineTransactionType] = [:] - - init() { - - } - - // MARK: - - - func connect(to server: HotlineServer) { - self.server = server - - let serverAddress = NWEndpoint.Host(server.address) - let serverPort = NWEndpoint.Port(rawValue: server.port)! - - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.enableKeepalive = true - tcpOptions.keepaliveInterval = 30 - let connectionParameters: NWParameters - connectionParameters = NWParameters(tls: nil, tcp: tcpOptions) - - self.connection = NWConnection(host: serverAddress, port: serverPort, using: connectionParameters) - 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() { - self.connection?.cancel() - self.connection = nil - - DispatchQueue.main.async { - self.connectionStatus = .disconnected - } - } - - // MARK: - - - private func sendTransaction(_ t: HotlineTransaction, autodisconnect disconnectOnError: Bool = true, callback: (() -> Void)? = nil) { - guard let c = connection else { - return - } - - print("HotlineClient => \(t.id) \(t.type)") - - self.transactionLog[t.id] = t.type - - c.send(content: t.encoded(), completion: .contentProcessed { [weak self] (error) in - if disconnectOnError, error != nil { - self?.disconnect() - return - } - - callback?() - }) - } - - private func receiveTransaction() { - guard let c = connection else { - print("HotlineClient: no connection for transaction.") - return - } - - print("HotlineClient ⏳") - c.receive(minimumIncompleteLength: HotlineTransaction.headerSize, maximumLength: HotlineTransaction.headerSize) { [weak self] (headerData, context, isComplete, error) in - guard let self = self else { - return - } - - if let error = error { - print("HotlineClient: transaction error \(error)") - self.disconnect() - return - } - - guard let headerData = headerData, !headerData.isEmpty else { - self.receiveTransaction() - return - } - -// print("HotlineClient: received \(headerData.count) header bytes") - - if var transaction = self.parseTransaction(data: headerData) { - // Receive additional data if the transaction has data attached to it. - print("DATA SIZE: \(transaction.dataSize)") - if transaction.dataSize > 0 { - c.receive(minimumIncompleteLength: Int(transaction.dataSize), maximumLength: Int(transaction.dataSize)) { [weak self] (fieldData, context, isComplete, error) in - guard let self = self else { - return - } - - guard let fieldData = fieldData, !fieldData.isEmpty else { - print("HotlineClient: transaction field data is empty!") - self.disconnect() - return - } - - let fieldCount = fieldData.readUInt16(at: 0)! - - if fieldCount > 0 { - var dataCursor = 2 - for _ in 0.. 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) { - - if let transactionType = HotlineTransactionType(rawValue: type) { - return HotlineTransaction(type: transactionType, flags: flags, isReply: isReply, id: id, errorCode: errorCode, totalSize: transactionSize, dataSize: dataSize) - } - else { - print("HotlineClient: Unknown type \(type) parsing with \(dataSize) bytes") - } - } - - return nil - } - - // MARK: - Messages - - 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 handshake failed \(err)") - 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 🤝") - self.sendLogin() { [weak self] in - self?.sendSetClientUserInfo() - self?.sendGetUserList() - } - self.receiveTransaction() - } - }) - } - - func sendLogin(callback: (() -> Void)? = nil) { - DispatchQueue.main.async { - self.connectionStatus = .loggingIn - } - - var t = HotlineTransaction(type: .login) - t.setFieldEncodedString(type: .userLogin, val: "") - t.setFieldEncodedString(type: .userPassword, val: "") - t.setFieldUInt16(type: .userIconID, val: self.userIconID) - t.setFieldString(type: .userName, val: self.userName) - t.setFieldUInt32(type: .versionNumber, val: 123) - - self.sendTransaction(t) { [weak self] in - DispatchQueue.main.async { - self?.connectionStatus = .loggedIn - } - - callback?() - } - } - - func sendSetClientUserInfo(callback: (() -> Void)? = nil) { - var t = HotlineTransaction(type: .setClientUserInfo) - t.setFieldString(type: .userName, val: self.userName) - t.setFieldUInt16(type: .userIconID, val: self.userIconID) - - self.sendTransaction(t, callback: callback) - } - - func sendAgree(callback: (() -> Void)? = nil) { - var t = HotlineTransaction(type: .agreed) - t.setFieldString(type: .userName, val: self.userName) - t.setFieldUInt16(type: .userIconID, val: self.userIconID) - t.setFieldUInt32(type: .options, val: 0) - self.sendTransaction(t, callback: callback) - } - - func sendChat(message: String, callback: (() -> Void)? = nil) { - var t = HotlineTransaction(type: .sendChat) - t.setFieldString(type: .data, val: message) - self.sendTransaction(t, callback: callback) - } - - func sendGetUserList(callback: (() -> Void)? = nil) { - let t = HotlineTransaction(type: .getUserNameList) - self.sendTransaction(t, callback: callback) - } - - // MARK: - Incoming - - private func processReply(_ transaction: HotlineTransaction) { - guard transaction.errorCode == 0 else { - if let errorParam = transaction.getField(type: .errorText), let errorText = errorParam.getString() { - print("HotlineClient 😵 \(transaction.errorCode): \(errorText)") - } - else { - print("HotlineClient 😵 \(transaction.errorCode)") - } - return - } - - guard let repliedTransactionType = self.transactionLog[transaction.id] else { - return - } - - print("HotlineClient reply in response to \(repliedTransactionType)") - - switch(repliedTransactionType) { - case .login: - print("GOT REPLY TO LOGIN!") - - if - let serverVersionField = transaction.getField(type: .versionNumber), - let serverVersion = serverVersionField.getUInt16() { - self.serverVersion = serverVersion - print("SERVER VERSION: \(serverVersion)") - } - case .getUserNameList: - print("GOT USER LIST") - var newUserList: [HotlineUser] = [] - for u in transaction.getFieldList(type: .userNameWithInfo) { - let info = u.getUserInfo() - print("USER: \(info)") - let user = HotlineUser(id: info.id, iconID: info.iconID, status: info.flags, name: info.userName) - newUserList.append(user) - } - DispatchQueue.main.async { - self.userList = newUserList - } - default: - break - } - } - - private func processTransaction(_ transaction: HotlineTransaction) { - if transaction.type == .reply { - print("HotlineClient <= \(transaction.type) to \(transaction.id):") - print(transaction) - } - else { - print("HotlineClient <= \(transaction.type)") - } - - switch(transaction.type) { - case .reply: - self.processReply(transaction) -// print("HotlineClient: Received reply transaction: \(transaction)") - - case .chatMessage: - if - let chatTextParam = transaction.getField(type: .data), - let chatText = chatTextParam.getString(), - let userNameParam = transaction.getField(type: .userName), - let userName = userNameParam.getString(), - let userIDParam = transaction.getField(type: .userID), - let userID = userIDParam.getUInt16() { - print("HotlineClient: \(userName):\(userID): \(chatText)") - DispatchQueue.main.async { - self.chatMessages.append(chatText) - } - } - case .getUserNameList: - print("HotlineClient: GOT USER NAME LIST!") - let userList = transaction.getFieldList(type: .userNameWithInfo) - for u in userList { - let userInfo = u.getUserInfo() - print("HotlineClient: user \(userInfo.userName)") - } - case .notifyOfUserChange: -// print("HotlineClient: user changed") - if let p = transaction.getField(type: .userName), - let userName = p.getString() { - print("HotlineClient: User changed \(userName)") - } - case .disconnectMessage: - print("HotlineClient ❌") - self.disconnect() - case .showAgreement: - if let noAgreementField = transaction.getField(type: .noServerAgreement) { - print("NO AGREEMENT?") - } - if let agreementParam = transaction.getField(type: .data) { - if let agreementText = agreementParam.getString() { - print("\n\n--------------------------\n") - print(agreementText) - print("\n--------------------------\n\n") - DispatchQueue.main.async { - self.agreement = agreementText - } -// self.sendAgree() { -// self.sendGetUserList() -// } - } - } - case .userAccess: - print("") - default: - print("HotlineClient: UNKNOWN transaction \(transaction.type) with \(transaction.fields.count) parameters") - print(transaction.fields) - } - } -} diff --git a/Hotline/Network/HotlineProtocol.swift b/Hotline/Network/HotlineProtocol.swift deleted file mode 100644 index 5372e78..0000000 --- a/Hotline/Network/HotlineProtocol.swift +++ /dev/null @@ -1,388 +0,0 @@ -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 HotlineUser: Identifiable, Hashable { - let id: UInt16 - let iconID: UInt16 - let status: UInt16 - let name: String - - var isAdmin: Bool { - return ((self.status & 0x0002) != 0) - } - - var isIdle: Bool { - return ((self.status & 0x0001) != 0) - } - - static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool { - return lhs.id == rhs.id - } - - init(id: UInt16, iconID: UInt16, status: UInt16, name: String) { - self.id = id - self.iconID = iconID - self.status = status - self.name = name - } - - init(from data: Data) { - self.id = data.readUInt16(at: 0)! - self.iconID = data.readUInt16(at: 2)! - self.status = data.readUInt16(at: 4)! - - let userNameLength = Int(data.readUInt16(at: 6)!) - self.name = data.readString(at: 8, length: userNameLength, encoding: .ascii)! - } - - func hash(into hasher: inout Hasher) { - hasher.combine(self.id) - } - - func encoded() -> Data { - var data = Data() - self.encode(to: &data) - return data - } - - func encode(to data: inout Data) { - data.appendUInt16(self.id) - data.appendUInt16(self.iconID) - data.appendUInt16(self.status) - - let userNameData = name.data(using: .ascii, allowLossyConversion: true)! - - data.appendUInt16(UInt16(userNameData.count)) - data.append(userNameData) - } -} - -struct HotlineUserInfo { - let id: UInt16 - let iconID: UInt16 - let flags: UInt16 - let userName: String - - init(data: Data) { - self.id = data.readUInt16(at: 0)! - self.iconID = data.readUInt16(at: 2)! - self.flags = data.readUInt16(at: 4)! - - let userNameLength = data.readUInt16(at: 6)! - self.userName = data.readString(at: 8, length: Int(userNameLength), encoding: .ascii)! - } -} - -struct HotlineTransactionField { - let type: HotlineTransactionFieldType - let dataSize: UInt16 - let data: Data - - init(type: HotlineTransactionFieldType, dataSize: UInt16, data: Data) { - self.type = type - self.dataSize = dataSize - self.data = data - } - - init(type: HotlineTransactionFieldType, val: UInt8) { - self.init(type: type, dataSize: UInt16(MemoryLayout.size), data: Data(val)) - } - - init(type: HotlineTransactionFieldType, val: UInt16) { - self.init(type: type, dataSize: UInt16(MemoryLayout.size), data: Data(val)) - } - - init(type: HotlineTransactionFieldType, val: UInt32) { - self.init(type: type, dataSize: UInt16(MemoryLayout.size), data: Data(val)) - } - - init(type: HotlineTransactionFieldType, string: String, encoding: String.Encoding = .ascii, encrypt: Bool = false) { - var stringInput = string - - if encrypt { - stringInput = String(string.utf8.map { char in - Character(UnicodeScalar(0xFF - char)) - }) - } - - var stringData: Data? - stringData = stringInput.data(using: encoding, allowLossyConversion: true) - if stringData == nil { - stringData = Data() - } - - self.init(type: type, dataSize: UInt16(stringData!.count), data: stringData!) - } - - init(type: HotlineTransactionFieldType, string: String, encrypt: Bool) { - self.init(type: type, string: string, encoding: .ascii, encrypt: encrypt) - } - - func getUInt8() -> UInt8? { - return self.data.readUInt8(at: 0) - } - - func getUInt16() -> UInt16? { - return self.data.readUInt16(at: 0) - } - - func getUInt32() -> UInt32? { - return self.data.readUInt32(at: 0) - } - - func getInteger() -> Int? { - switch(self.data.count) { - case 1: - if let val = self.getUInt8() { - return Int(val) - } - case 2: - if let val = self.getUInt16() { - return Int(val) - } - case 4: - if let val = self.getUInt32() { - return Int(val) - } - default: - break - } - - return nil - } - - func getString(encoding: String.Encoding = .ascii) -> String? { - return String(data: self.data, encoding: encoding) - } - - func getUserInfo() -> HotlineUserInfo { - return HotlineUserInfo(data: self.data) - } -} - -struct HotlineTransaction { - static let headerSize = 20 - static var sequenceID: UInt32 = 1 - - static func nextID() -> UInt32 { - HotlineTransaction.sequenceID += 1 - return HotlineTransaction.sequenceID - } - - var flags: UInt8 = 0 - var isReply: UInt8 = 0 - var type: HotlineTransactionType - var id: UInt32 = HotlineTransaction.nextID() - var errorCode: UInt32 = 0 - var totalSize: UInt32 = UInt32(HotlineTransaction.headerSize) - var dataSize: UInt32 = 0 - - var fields: [HotlineTransactionField] = [] - - init(type: HotlineTransactionType) { - self.type = type - } - - init(type: HotlineTransactionType, flags: UInt8, isReply: UInt8, id: UInt32, errorCode: UInt32, totalSize: UInt32, dataSize: UInt32) { - self.type = type - self.flags = flags - self.isReply = isReply - self.id = id - self.errorCode = errorCode - self.totalSize = totalSize - self.dataSize = dataSize - } - - mutating func setFieldUInt8(type: HotlineTransactionFieldType, val: UInt8) { - self.fields.append(HotlineTransactionField(type: type, val: val)) - } - - mutating func setFieldUInt16(type: HotlineTransactionFieldType, val: UInt16) { - self.fields.append(HotlineTransactionField(type: type, val: val)) - } - - mutating func setFieldUInt32(type: HotlineTransactionFieldType, val: UInt32) { - self.fields.append(HotlineTransactionField(type: type, val: val)) - } - - mutating func setFieldEncodedString(type: HotlineTransactionFieldType, val: String) { - self.fields.append(HotlineTransactionField(type: type, string: val, encrypt: true)) - } - - mutating func setFieldString(type: HotlineTransactionFieldType, val: String) { - self.fields.append(HotlineTransactionField(type: type, string: val)) - } - - func getField(type: HotlineTransactionFieldType) -> HotlineTransactionField? { - return self.fields.first { p in - p.type == type - } - } - - func getFieldList(type: HotlineTransactionFieldType) -> [HotlineTransactionField] { - return self.fields.filter { p in - p.type == type - } - } - - func encoded() -> Data { - var data = Data() - self.encode(to: &data) - return data - } - - 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) - - if self.fields.count > 0 { - var fieldData = Data() - fieldData.appendUInt16(UInt16(self.fields.count)) - for f in self.fields { - fieldData.appendUInt16(f.type.rawValue) - fieldData.appendUInt16(f.dataSize) - fieldData.append(f.data) - } - - data.appendUInt32(UInt32(fieldData.count)) - data.appendUInt32(UInt32(fieldData.count)) - data.append(fieldData) - } - else { - data.appendUInt32(2) - data.appendUInt32(2) - data.appendUInt16(0) - } - } -} - -enum HotlineTransactionFieldType: UInt16 { - case errorText = 100 // String - case data = 101 // String - case userName = 102 // String - case userID = 103 // Integer - case userIconID = 104 // Integer - case userLogin = 105 // Encoded string - case userPassword = 106 // Encoded string - case referenceNumber = 107 // Integer - case transferSize = 108 // Integer - case chatOptions = 109 // Integer - case userAccess = 110 // 64-bit integer? - case userAlias = 111 // ??? - case userFlags = 112 // Integer - case options = 113 // 32-bit integer? - case chatID = 114 // Integer - case chatSubject = 115 // String - case waitingCount = 116 // Integer - case serverAgreement = 150 // ??? - case serverBanner = 151 // Data? - case serverBannerType = 152 // Integer - case serverBannerURL = 153 // String - case noServerAgreement = 154 // Integer - case versionNumber = 160 // Integer - case communityBannerID = 161 // Integer - case serverName = 162 // String - // TODO: Add file field types - case quotingMessage = 214 // String? - case automaticResponse = 215 // String - case folderItemCount = 220 // Integer - case userNameWithInfo = 300 // Data { user id: 2, icon id: 2, user flags: 2, user name size: 2, user name: size } - case newsCategoryGUID = 319 // Data? - case newsCategoryListData = 320 // Data { type: 1 (1 = folder, 10 = category, 255 = other), category name: rest } - case newsArticleListData = 321 // Data - case newsCategoryName = 322 // String - case newsCategoryListData15 = 323 // Data - case newsPath = 325 // Data - case newsArticleID = 326 // Integer - case newsArticleDataFlavor = 327 // String - case newsArticleTitle = 328 // String - case newsArticlePoster = 329 // String - case newsArticleDate = 330 // Data { year: 2, ms: 2, secs: 4 } - case newsArticlePrevious = 331 // Integer - case newsArticleNext = 332 // Integer - case newsArticleData = 333 // Data - case newsArticleFlags = 334 // Integer - case newsArticleParentArticle = 335 // Integer - case newsArticleFirstChildArticle = 336 // Integer - case newsArticleRecursiveDelete = 337 // Integer -} - -enum HotlineTransactionType: UInt16 { - case reply = 0 - case error = 100 - case getMessages = 101 - case newMessage = 102 // Server - case oldPostNews = 103 - case serverMessage = 104 // Server - case sendChat = 105 - case chatMessage = 106 // Server - case login = 107 - case sendInstantMessage = 108 - case showAgreement = 109 // Server - case disconnectUser = 110 - case disconnectMessage = 111 // Server - case inviteToNewChat = 112 - case inviteToChat = 113 // Server - case rejectChatInvite = 114 - case joinChat = 115 - case leaveChat = 116 - case notifyChatOfUserChange = 117 // Server - case notifyChatOfUserDelete = 118 // Server - case notifyChatSubject = 119 // Server - case setChatSubject = 120 - case agreed = 121 - case serverBanner = 122 // Server - 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 // Server - case downloadBanner = 212 - case uploadFolder = 213 - case getUserNameList = 300 - case notifyOfUserChange = 301 // Server - case notifyOfUserDelete = 302 // Server - case getClientInfoText = 303 - case setClientUserInfo = 304 - case newUser = 350 - case deleteUser = 351 - case getUser = 352 - case setUser = 353 - case userAccess = 354 // Server - case userBroadcast = 355 // Client & Server - case getNewsCategoryNameList = 370 - case getNewsArticleNameList = 371 - case deleteNewsItem = 380 - case newNewsFolder = 381 - case newNewsCategory = 382 - case getNewsArticleData = 400 - case postNewsArticle = 410 - case deleteNewsArticle = 411 - case connectionKeepAlive = 500 -} - diff --git a/Hotline/Network/HotlineTracker.swift b/Hotline/Network/HotlineTracker.swift deleted file mode 100644 index 2c44bc8..0000000 --- a/Hotline/Network/HotlineTracker.swift +++ /dev/null @@ -1,260 +0,0 @@ -import Foundation -import Network - -enum HotlineTrackerStatus: Int { - case disconnected - case connecting - case connected -} - -class HotlineTracker : ObservableObject { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port = NWEndpoint.Port(rawValue: 5498)! - - 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) { - self.serverAddress = NWEndpoint.Host(address) - } - - 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") - DispatchQueue.main.async { - self?.connectionStatus = .connected - } - self?.sendMagic() - case .cancelled: - print("CONNECTION CANCELLED") - DispatchQueue.main.async { - self?.connectionStatus = .disconnected - } - case .failed(let err): - print("CONNECTION ERROR \(err)") - DispatchQueue.main.async { - self?.connectionStatus = .disconnected - } - default: - print("CONNECTION OTHER THING") - } - } - - DispatchQueue.main.async { - 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 foundServers: [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)") - - foundServers.append(server) - - cursor += 11 + nameLength + 1 + descLength - } - } - } - } - - print(cursor) - } - - DispatchQueue.main.async { -// print("CALLING CALLBACK") - self.servers = foundServers - } - - } -} diff --git a/Hotline/TrackerView.swift b/Hotline/TrackerView.swift deleted file mode 100644 index 1b5cce5..0000000 --- a/Hotline/TrackerView.swift +++ /dev/null @@ -1,100 +0,0 @@ -import SwiftUI -import SwiftData - -struct AgreementView: View { - @Environment(\.dismiss) var dismiss - - let text: String - - var body: some View { - Text(text) - } -} - -struct TrackerServerView: View { - @Environment(\.dismiss) var dismiss - - let server: HotlineServer - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text("🌎") - Text(server.name!).bold().dynamicTypeSize(.xxLarge) - } - .padding(EdgeInsets(top: 0, leading: 0, bottom: 8.0, trailing: 0)) - Text(server.description!).opacity(0.6).dynamicTypeSize(.xLarge).padding(EdgeInsets(top: 0, leading: 0, bottom: 8.0, trailing: 0)) - Text(server.address).opacity(0.3).dynamicTypeSize(.medium) - Spacer() - HStack(alignment: .center) { - Button("Connect") { - print("WHAT", "HELLO", server.address) - HotlineClient.shared.connect(to: server) - dismiss() -// client = HotlineClient(server: selectedServer) - } - .bold() - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - .frame(maxWidth: .infinity) - .foregroundColor(.black) - .background(LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom)) - . - overlay( - RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(0.4) - ) - .cornerRadius(10.0) - } - } - .padding(EdgeInsets(top: 28.0, leading: 24.0, bottom: 24.0, trailing: 24.0)) - .presentationDetents([.fraction(0.4)]) - .presentationDragIndicator(.automatic) - } -} - -struct TrackerView: View { - - // @Environment(\.modelContext) private var modelContext - // @Query private var items: [Item] - - @StateObject var tracker = HotlineTracker(address: "hltracker.com") - @StateObject var client = HotlineClient.shared - - @State private var selectedServer: HotlineServer? - @State private var showingAgreement = false - @State private var showingConnectSheet = false - - var body: some View { - List(selection: $selectedServer) { - ForEach(tracker.servers) { server in - HStack { - Text("🌎") - Text(server.name!).bold() - Spacer() - Text("\(server.users)").opacity(0.6) - } - .listRowBackground(Color(white: 0.96)) - .listRowInsets(EdgeInsets(top: 0, leading: 16.0, bottom: 0, trailing: 16.0)) - .listRowSeparator(.visible, edges: VerticalEdge.Set.all) - .listRowSeparatorTint(Color(white: 1.0)) - .tag(server) - } - } - .background(Color(white: 0.96)) - .listStyle(.plain) - .frame(maxWidth: .infinity) - .task { - tracker.fetch() - } - .sheet(item: $selectedServer) { item in - TrackerServerView(server: item) - } - .sheet(isPresented: Binding(get: { client.agreement != nil }, set: { _ in })) { - AgreementView(text: client.agreement!) - } - } -} - -#Preview { - TrackerView() - // .modelContainer(for: Item.self, inMemory: true) -} diff --git a/Hotline/Views/AgreementView.swift b/Hotline/Views/AgreementView.swift new file mode 100644 index 0000000..c61c9d7 --- /dev/null +++ b/Hotline/Views/AgreementView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct AgreementView: View { + @Environment(\.dismiss) var dismiss + + let text: String + + var body: some View { + VStack(alignment: .leading) { + ScrollView { + VStack(alignment: .leading) { + Text(text) + .fontDesign(.monospaced) + .padding() + .dynamicTypeSize(.small) + .textSelection(.enabled) + } + } + Button("OK") { + print("DONE") + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + } + } +} + +#Preview { + AgreementView(text: """ +Welcome! + +Take it on real one. +""") +} diff --git a/Hotline/Views/ChatView.swift b/Hotline/Views/ChatView.swift new file mode 100644 index 0000000..532a890 --- /dev/null +++ b/Hotline/Views/ChatView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct ChatView: View { +// @Binding private var input: String + @State var input: String = "" + @State private var scrollPos: Int? + @State private var contentHeight: CGFloat = 0 + + var body: some View { + VStack(spacing: 0) { + GeometryReader { geometry in + ScrollView(.vertical) { + ScrollViewReader { scrollReader in + VStack(alignment: .leading) { + Spacer() + LazyVStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text("bolt:").bold().fontDesign(.monospaced).frame(minWidth: 60) + Text("hello!").fontDesign(.monospaced).textSelection(.enabled) + } + ForEach(0..<50) { i in + HStack(alignment: .firstTextBaseline) { + Text("mierau:").bold().fontDesign(.monospaced) + Text("g'day to you. what's going on this afternoon?") + .fontDesign(.monospaced) + .textSelection(.enabled) + } + } + Text("").font(.system(size: 0)).id("bottomScroll") + } + .padding() + } + // .frame(width: geometry.size.width) + .frame(minHeight: geometry.size.height) +// .background(Color.red) + .onAppear() { + scrollReader.scrollTo("bottomScroll", anchor: .bottom) + } + // .onChange() { + // scrollReader.scrollTo(10000, anchor: .bottomTrailing) + // } + } + } + } + + Divider() + + HStack(alignment: .top) { + Image(systemName: "chevron.right") + TextField("This is a chat topic", text: $input, axis: .vertical) + .lineLimit(1...5) + .onSubmit { + // HotlineClient.shared.sendChat(message: self.input) + self.input = "" + } + }.padding() + } + } +} + +#Preview { + ChatView() +} diff --git a/Hotline/Views/HotlineView.swift b/Hotline/Views/HotlineView.swift new file mode 100644 index 0000000..67ef067 --- /dev/null +++ b/Hotline/Views/HotlineView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct HotlineView: View { + @Environment(HotlineClient.self) private var hotline + @Environment(HotlineTrackerClient.self) private var tracker + + @State private var isTrackerVisible = false + + var body: some View { + NavigationStack { + TrackerView() + } + } +} + +#Preview { + HotlineView() + .environment(HotlineClient()) + .environment(HotlineTrackerClient(tracker: HotlineTracker("hltracker.com"))) +} diff --git a/Hotline/Views/ServerView.swift b/Hotline/Views/ServerView.swift new file mode 100644 index 0000000..1610aa7 --- /dev/null +++ b/Hotline/Views/ServerView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct ServerView: View { + @Environment(HotlineClient.self) private var hotline + @Environment(HotlineTrackerClient.self) private var tracker + + @State private var isTrackerVisible = false + + var body: some View { + TabView { + NavigationView { + ChatView() + .navigationTitle("Badmoon") + .navigationBarItems( + leading: Button(action: { + withAnimation { + isTrackerVisible.toggle() + } + }) { + Image(systemName: "line.horizontal.3") // Hamburger icon or similar + .imageScale(.large) + } + ) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarBackground(.red, for: .navigationBar) + } + .navigationBarTitleDisplayMode(.inline) + .tabItem { + Image(systemName: "message") + } + + Text("Users") + .tabItem { + Image(systemName: "person.fill") + } + + Text("News") + .tabItem { + Image(systemName: "newspaper") + } + + Text("Files") + .tabItem { + Image(systemName: "folder") + } + + Text("Transfers") + .tabItem { + Image(systemName: "network") + } + } +// .sheet(isPresented: Binding(get: { hotline.connectionStatus != .loggedIn }, set: { _ in })) { +// TrackerView() +// } + } +} + +#Preview { + ServerView() + .environment(HotlineClient()) + .environment(HotlineTrackerClient(tracker: HotlineTracker("hltracker.com"))) +} diff --git a/Hotline/Views/TrackerServerView.swift b/Hotline/Views/TrackerServerView.swift new file mode 100644 index 0000000..5be6f7a --- /dev/null +++ b/Hotline/Views/TrackerServerView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct TrackerServerView: View { + @Environment(\.dismiss) var dismiss + + let server: HotlineServer + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text("🌎").dynamicTypeSize(.xxxLarge) + Text(server.name!).bold().dynamicTypeSize(.xxxLarge) + } + .padding(EdgeInsets(top: 0, leading: 0, bottom: 8.0, trailing: 0)) + + Text(server.description!).opacity(0.6).dynamicTypeSize(.xLarge).padding(EdgeInsets(top: 0, leading: 0, bottom: 8.0, trailing: 0)).textSelection(.enabled) + Text(server.address).opacity(0.3).dynamicTypeSize(.medium).textSelection(.enabled) + + Spacer() + + HStack(alignment: .center) { + Button("Connect") { + print("WHAT", "HELLO", server.address) +// HotlineClient.shared.connect(to: server) + dismiss() + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(.black) + .background(LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom)) + . + overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(0.4) + ) + .cornerRadius(10.0) + } + } + .padding(EdgeInsets(top: 28.0, leading: 24.0, bottom: 24.0, trailing: 24.0)) + .presentationDetents([.fraction(0.4)]) + .presentationDragIndicator(.automatic) + } +} + +#Preview { + TrackerServerView(server: HotlineServer(address: "192.168.1.1", port: 5050, users: 5, name: "Ye Olde Server", description: "This is a server")) +} diff --git a/Hotline/Views/TrackerView.swift b/Hotline/Views/TrackerView.swift new file mode 100644 index 0000000..5d1afe2 --- /dev/null +++ b/Hotline/Views/TrackerView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct TrackerView: View { + + // @Environment(\.modelContext) private var modelContext + // @Query private var items: [Item] + + @Environment(HotlineClient.self) private var hotline + @Environment(HotlineTrackerClient.self) private var tracker + + // @StateObject var tracker = HotlineTrackerClient(address: "hltracker.com") + // @StateObject var client = HotlineClient.shared + + @State private var selectedServer: HotlineServer? + @State private var showingAgreement = false + @State private var showingConnectSheet = false + + // @Bindable var trackerTest = tracker + + var body: some View { + List(selection: $selectedServer) { + ForEach(tracker.servers) { server in + HStack { + Text("🌎") + Text(server.name!).bold() + Spacer() + Text("\(server.users)").opacity(0.6) + } + .listRowBackground(Color(white: 0.96)) + .listRowInsets(EdgeInsets(top: 0, leading: 16.0, bottom: 0, trailing: 16.0)) + .listRowSeparator(.visible, edges: VerticalEdge.Set.all) + .listRowSeparatorTint(Color(white: 1.0)) + .tag(server) + } + } + .background(Color(white: 0.96)) + .listStyle(.plain) + .frame(maxWidth: .infinity) + .task { + tracker.fetch() + } + .sheet(item: $selectedServer) { item in + TrackerServerView(server: item) + } + .sheet(isPresented: Binding(get: { hotline.agreement != nil }, set: { _ in })) { + AgreementView(text: hotline.agreement!) + } + .navigationTitle("Tracker") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Image(systemName: "gearshape.fill") + } + // ToolbarItem(placement: .topBarTrailing) { + // Image(systemName: "camera.fill") + // } + // ToolbarItem(placement: .principal) { + // Text("Username") + // } + } + .refreshable { + tracker.fetch() + } + .interactiveDismissDisabled() + } +} + +#Preview { + TrackerView() + .environment(HotlineClient()) + .environment(HotlineTrackerClient(tracker: HotlineTracker("hltracker.com"))) + // .modelContainer(for: Item.self, inMemory: true) +} diff --git a/Research/hotline 2 constants.pdf b/Research/hotline 2 constants.pdf new file mode 100644 index 0000000..48594d4 Binary files /dev/null and b/Research/hotline 2 constants.pdf differ diff --git a/Research/hotline access bits.pdf b/Research/hotline access bits.pdf new file mode 100644 index 0000000..af3be66 Binary files /dev/null and b/Research/hotline access bits.pdf differ diff --git a/Research/hotline date params.pdf b/Research/hotline date params.pdf new file mode 100644 index 0000000..a594e0a Binary files /dev/null and b/Research/hotline date params.pdf differ diff --git a/Research/hotline path params.pdf b/Research/hotline path params.pdf new file mode 100644 index 0000000..677afc2 Binary files /dev/null and b/Research/hotline path params.pdf differ diff --git a/Research/hotline protocol good.pdf b/Research/hotline protocol good.pdf new file mode 100644 index 0000000..fd52309 Binary files /dev/null and b/Research/hotline protocol good.pdf differ diff --git a/Research/hotline server protocol.pdf b/Research/hotline server protocol.pdf new file mode 100644 index 0000000..d5d5c6a Binary files /dev/null and b/Research/hotline server protocol.pdf differ diff --git a/Research/hotline trackers.pdf b/Research/hotline trackers.pdf new file mode 100644 index 0000000..96af1b6 Binary files /dev/null and b/Research/hotline trackers.pdf differ diff --git a/Research/hotline transactions.pdf b/Research/hotline transactions.pdf new file mode 100644 index 0000000..8dbe1f0 Binary files /dev/null and b/Research/hotline transactions.pdf differ -- cgit