diff options
| author | Dustin Mierau <dustin@mierau.me> | 2023-12-11 23:32:28 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2023-12-11 23:32:28 -0800 |
| commit | 18c4b8643df09be4ce52a4b110ea21ac1ad053fc (patch) | |
| tree | 335d4f0a6b430b983b6f00567b91eb1ee6b16b4b /Hotline | |
| parent | 84aaa9ef0a1be6986e38d7e4f58e07d3cb84979e (diff) | |
Move to new Hotline model for data observation and let client exist standalone.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 4 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineNewClient.swift | 781 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineProtocol.swift | 2 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineTrackerClient.swift | 8 | ||||
| -rw-r--r-- | Hotline/HotlineApp.swift | 2 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 303 | ||||
| -rw-r--r-- | Hotline/Models/Server.swift | 2 | ||||
| -rw-r--r-- | Hotline/Models/Tracker.swift | 11 | ||||
| -rw-r--r-- | Hotline/Views/ChatView.swift | 38 | ||||
| -rw-r--r-- | Hotline/Views/FilesView.swift | 31 | ||||
| -rw-r--r-- | Hotline/Views/MessageBoardView.swift | 27 | ||||
| -rw-r--r-- | Hotline/Views/ServerView.swift | 36 | ||||
| -rw-r--r-- | Hotline/Views/TrackerView.swift | 214 | ||||
| -rw-r--r-- | Hotline/Views/UsersView.swift (renamed from Hotline/Views/UserListView.swift) | 16 |
14 files changed, 1360 insertions, 115 deletions
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index f545c3c..9d686e2 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -336,7 +336,7 @@ class HotlineClient { } func sendGetMessageBoard(callback: (() -> Void)? = nil) { - let t = HotlineTransaction(type: .getMessages) + let t = HotlineTransaction(type: .getMessageBoard) self.sendTransaction(t, callback: callback) } @@ -468,7 +468,7 @@ class HotlineClient { print("HotlineClient got users:\n") print("\(self.userList)\n\n") } - case .getMessages: + case .getMessageBoard: if let textField = transaction.getField(type: .data), let text = textField.getString() { var messages: [String] = [] let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ diff --git a/Hotline/Hotline/HotlineNewClient.swift b/Hotline/Hotline/HotlineNewClient.swift new file mode 100644 index 0000000..2861fbc --- /dev/null +++ b/Hotline/Hotline/HotlineNewClient.swift @@ -0,0 +1,781 @@ +import Foundation +import Network + +enum HotlineNewClientStatus: Int { + case disconnected + case connecting + case connected + case loggingIn + case loggedIn +} + +enum HotlineTransactionError: Error { + case networkFailure + case error(UInt32, String?) + case invalidMessage(UInt32, String?) +} + +//struct HotlineTransactionError { +// let code: UInt32 +// let message: String +//} + +struct HotlineTransactionInfo { + let type: HotlineTransactionType + let callback: ((HotlineTransaction) -> Void)? + let reply: ((HotlineTransaction) -> Void)? +} + +//struct HotlineAccount { +// let username: String +// let iconID: UInt16 +//} + +protocol HotlineNewClientDelegate: AnyObject { + func hotlineGetUserInfo() -> (String, UInt16) + func hotlineStatusChanged(status: HotlineNewClientStatus) + func hotlineReceivedAgreement(text: String) + func hotlineReceivedChatMessage(message: String) + func hotlineReceivedUserList(users: [HotlineUser]) + func hotlineReceivedServerMessage(message: String) + func hotlineUserChanged(user: HotlineUser) + func hotlineUserDisconnected(userID: UInt16) +} + +extension HotlineNewClientDelegate { + func hotlineStatusChanged(status: HotlineNewClientStatus) {} + func hotlineReceivedAgreement(text: String) {} + func hotlineReceivedChatMessage(message: String) {} + func hotlineReceivedUserList(users: [HotlineUser]) {} + func hotlineReceivedServerMessage(message: String) {} + func hotlineUserChanged(user: HotlineUser) {} + func hotlineUserDisconnected(userID: UInt16) {} +} + +class HotlineNewClient { + // 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 + ]) + + weak var delegate: HotlineNewClientDelegate? + + var connectionStatus: HotlineNewClientStatus = .disconnected + var connectCallback: ((Bool) -> Void)? +// var chatMessages: [HotlineChat] = [] +// var messageBoardMessages: [String] = [] + var fileList: [HotlineFile] = [] + var newsCategories: [HotlineNewsCategory] = [] + + // var username: String = "guest" + // var iconID: UInt16 + // var userIconID: UInt16 = 128 + var serverVersion: UInt16 = 151 + // var server: HotlineServer? + + private var connection: NWConnection? +// private var connectionContinuation: CheckedContinuation<Bool, Never>? + private var transactionLog: [UInt32:(HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] = [:] + + init() { + // let downloadsPath = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask) + // print("DOWNLOAD TO: \(downloadsPath)") + } + + // MARK: - + + func login(_ address: String, port: UInt16, login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, UInt16) -> Void)?) -> Bool { + print("AWAITING CONNECT") + self.connect(address: address, port: port) { [weak self] success in + guard success else { + DispatchQueue.main.async { + callback?(.networkFailure, 0) + } + return + } + + print("AWAITING HANDSHAKE") + self?.sendHandshake() { [weak self] success in + guard success else { + DispatchQueue.main.async { + callback?(.networkFailure, 0) + } + return + } + + print("AWAITING LOGIN") + self?.sendLogin(login: login, password: password, username: username, iconID: iconID) { [weak self] err, serverVersion in + guard err == nil else { + DispatchQueue.main.async { + callback?(err, 0) + } + return + } + + self?.serverVersion = serverVersion + print("SERVER VERSION: \(serverVersion)") + + self?.sendSetClientUserInfo(username: username, iconID: iconID) + self?.sendGetUserList() + + DispatchQueue.main.async { + callback?(nil, serverVersion) + } + + } + } + } + + return false + } + + private func connect(address: String, port: UInt16, callback: ((Bool) -> Void)?) { + let serverAddress = NWEndpoint.Host(address) + let serverPort = NWEndpoint.Port(rawValue: port)! + + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveInterval = 30 + let connectionParameters: NWParameters + connectionParameters = NWParameters(tls: nil, tcp: tcpOptions) + + self.connectCallback = callback + + self.connection = NWConnection(host: serverAddress, port: serverPort, using: connectionParameters) + self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in + guard let self = self else { + return + } + + switch newState { + case .preparing: + print("HotlineClient: connection preparing...") + case .setup: + print("HotlineClient: connection setup") + case .waiting(let err): + print("HotlineClient: connection waiting \(err)...") + case .ready: + print("HotlineClient: connection ready!") + self.updateConnectionStatus(.connected) + self.connectCallback?(true) + self.connectCallback = nil +// if self.connectionContinuation != nil { +// let continuation = self.connectionContinuation! +// self.connectionContinuation = nil +// callback?(true) +// continuation.resume(returning: true) +// } +// callback?(true) + case .failed(let err): + print("HotlineClient: connection error \(err)") + self.updateConnectionStatus(.disconnected) + self.reset() + self.connectCallback?(false) + self.connectCallback = nil +// callback?(false) +// if self.connectionContinuation != nil { +// let continuation = self.connectionContinuation! +// self.connectionContinuation = nil +// continuation.resume(returning: false) +// } + case .cancelled: + print("HotlineClient: connection cancelled") + self.updateConnectionStatus(.disconnected) + self.reset() + self.connectCallback?(false) + self.connectCallback = nil +// callback?(false) +// if self.connectionContinuation != nil { +// let continuation = self.connectionContinuation! +// self.connectionContinuation = nil +// continuation.resume(returning: false) +// } + default: + break + } + } + + self.updateConnectionStatus(.connecting) + self.connection?.start(queue: .global()) + +// return await withCheckedContinuation { [weak self] continuation in +// self?.connectionContinuation = continuation +// self?.connection?.start(queue: .global()) +// } + } + + private func reset() { + self.transactionLog = [:] + DispatchQueue.main.async { +// self.chatMessages = [] +// self.messageBoardMessages = [] + self.fileList = [] + self.newsCategories = [] + } + } + + func disconnect() { + self.connection?.cancel() + self.connection = nil + } + + private func updateConnectionStatus(_ status: HotlineNewClientStatus) { + self.connectionStatus = status + DispatchQueue.main.async { [weak self] in + self?.delegate?.hotlineStatusChanged(status: status) + } + } + + // MARK: - + + private func sendTransaction(_ t: HotlineTransaction, sent sentCallback: ((Bool) -> Void)? = nil, reply replyCallback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { + guard let c = connection else { + return + } + + print("HotlineClient => \(t.id) \(t.type)") + + if replyCallback != nil { + self.transactionLog[t.id] = (t.type, replyCallback) + } + + c.send(content: t.encoded(), completion: .contentProcessed { [weak self] (error) in + if error != nil { + sentCallback?(false) + self?.transactionLog[t.id] = nil + self?.disconnect() + return + } + + sentCallback?(true) + }) + } + + private func sendTransaction(_ t: HotlineTransaction, sent sentCallback: ((Bool) -> Void)? = nil) { + self.sendTransaction(t, sent: sentCallback, reply: nil) + } + + 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..<fieldCount { + if + let fieldID = fieldData.readUInt16(at: dataCursor), + let fieldSize = fieldData.readUInt16(at: dataCursor + 2), + let fieldRemainingData = fieldData.readData(at: dataCursor + 4, length: Int(fieldSize)) { + + if let fieldType = HotlineTransactionFieldType(rawValue: fieldID) { + + transaction.fields.append(HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData)) + // transaction.parameters[fieldType] = HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldData) + } + else { + print("HotlineClient: UNKNOWN FIELD TYPE!", fieldID, fieldSize) + } + + dataCursor += 4 + Int(fieldSize) + } + } + + // Process the transaction if we have processed more than zero parameters here + // as we expect parameters at this point. + self.processTransaction(transaction) + } + + // Continue receiving transactions. + self.receiveTransaction() + } + } + else { + // In this case we have no further data to receive so we simply + // process the transaction and then continue receiving. + self.processTransaction(transaction) + self.receiveTransaction() + } + } + else { + // Here we failed to parse the current transaction. + // We should consider disconnecting perhaps. + // But for now we'll continue receiving. + self.receiveTransaction() + } + } + } + + private func parseTransaction(data: Data) -> HotlineTransaction? { + if + let flags = data.readUInt8(at: 0), + let isReply = data.readUInt8(at: 1), + let type = data.readUInt16(at: 2), + let id = data.readUInt32(at: 4), + let errorCode = data.readUInt32(at: 8), + let transactionSize = data.readUInt32(at: 12), + let dataSize = data.readUInt32(at: 16) { + + 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(callback: ((Bool) -> Void)?) { + guard let c = self.connection else { + print("HotlineClient: invalid connection to send handshake.") + return + } + + c.send(content: HotlineNewClient.handshakePacket, completion: .contentProcessed { [weak self] (error) in + if let err = error { + print("HotlineClient: sending handshake failed \(err)") + callback?(false) + 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() + callback?(false) + return + } + + let protocolID = data.readUInt32(at: 0)! + if protocolID != 0x54525450 { // 'TRTP' + print("HotlineClient: invalid handshake protocol ID \(protocolID)") + self.disconnect() + callback?(false) + return + } + + let errorCode = data.readUInt32(at: 4)! + if errorCode != 0 { // 0 == no error + print("HotlineClient: handshake error", errorCode) + self.disconnect() + callback?(false) + return + } + + callback?(true) + print("HotlineClient 🤝") + self.receiveTransaction() + } + }) + } + + func sendLogin(login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, UInt16) -> Void)?) { + self.updateConnectionStatus(.loggingIn) + + var t = HotlineTransaction(type: .login) + t.setFieldEncodedString(type: .userLogin, val: login) + t.setFieldEncodedString(type: .userPassword, val: password) + t.setFieldUInt16(type: .userIconID, val: iconID) + t.setFieldString(type: .userName, val: username) + t.setFieldUInt32(type: .versionNumber, val: 123) + + self.sendTransaction(t) { success in + if !success { + DispatchQueue.main.async { + callback?(.networkFailure, 0) + } + } + } reply: { [weak self] replyTransaction, err in + print("GOT LOGIN REPLY") + self?.updateConnectionStatus(.loggedIn) + + var serverVersion: UInt16? + if + let serverVersionField = replyTransaction.getField(type: .versionNumber), + let serverVersionValue = serverVersionField.getUInt16() { + self?.serverVersion = serverVersionValue + serverVersion = serverVersionValue + print("SERVER VERSION: \(serverVersionValue)") + } + + DispatchQueue.main.async { + callback?(err, serverVersion ?? 0) + } + } + } + + func sendSetClientUserInfo(username: String, iconID: UInt16, sent: ((Bool) -> Void)? = nil) { + var t = HotlineTransaction(type: .setClientUserInfo) + t.setFieldString(type: .userName, val: username) + t.setFieldUInt16(type: .userIconID, val: iconID) + self.sendTransaction(t, sent: sent) + } + + func sendAgree(sent: ((Bool) -> 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, sent: sent) + } + + func sendChat(message: String, sent sentCallback: ((Bool) -> Void)?) { + var t = HotlineTransaction(type: .sendChat) + t.setFieldString(type: .data, val: message) + self.sendTransaction(t, sent: sentCallback) + } + + func sendGetUserList(sent sentCallback: ((Bool) -> Void)? = nil) { + print("SENDING GET USER LIST") + let t = HotlineTransaction(type: .getUserNameList) + self.sendTransaction(t, sent: sentCallback) { [weak self] replyTransaction, err in + print("GOT USER LIST") + var newUsers: [UInt16:HotlineUser] = [:] + var newUserList: [HotlineUser] = [] + for u in replyTransaction.getFieldList(type: .userNameWithInfo) { + let user = u.getUser() + newUsers[user.id] = user + newUserList.append(user) + } + DispatchQueue.main.async { [weak self] in + self?.delegate?.hotlineReceivedUserList(users: newUserList) + } + } + } + + func sendGetMessageBoard(callback: ((HotlineTransactionError?, [String]) -> Void)?) { + let t = HotlineTransaction(type: .getMessageBoard) + self.sendTransaction(t) { success in + if !success { + DispatchQueue.main.async { + callback?(.networkFailure, []) + } + } + } reply: { replyTransaction, err in + if err != nil { + DispatchQueue.main.async { + callback?(err, []) + } + return + } + + if let textField = replyTransaction.getField(type: .data), + let text = textField.getString() { + var messages: [String] = [] + let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ + let matches = text.matches(of: messageBoardRegex) + var start = text.startIndex + + if matches.count > 0 { + for match in matches { + let range = match.range + messages.append(String(text[start..<range.lowerBound])) + start = range.upperBound + } + } + else { + messages.append(text) + } + + DispatchQueue.main.async { + callback?(err, messages) + } + + + +// continuation.resume(returning: messages) +// DispatchQueue.main.async { +// self.messageBoardMessages = messages +// } + } + } + } + + func sendGetNewsCategories(sent: ((Bool) -> Void)? = nil) { + let t = HotlineTransaction(type: .getNewsCategoryNameList) + self.sendTransaction(t, sent: sent) + } + + func sendGetNewsArticles(path: [String]? = nil, sent: ((Bool) -> Void)? = nil, reply: ((String) -> Void)? = nil) { + var t = HotlineTransaction(type: .getNewsArticleNameList) + if path != nil { + t.setFieldPath(type: .newsPath, val: path!) + } + self.sendTransaction(t, sent: sent) + } + + func sendGetFileList(path: [String] = [], sent: ((Bool) -> Void)? = nil, reply: (([HotlineFile]) -> Void)? = nil) { + var t = HotlineTransaction(type: .getFileNameList) + + if !path.isEmpty { + t.setFieldPath(type: .filePath, val: path) + } + + self.sendTransaction(t, sent: sent, reply: { r, err in + if err != nil { + reply?([]) + return + } + + var files: [HotlineFile] = [] + for fi in r.getFieldList(type: .fileNameWithInfo) { + let file = fi.getFile() + file.path = path + [file.name] + files.append(file) + } + + DispatchQueue.main.async { + reply?(files) + } + }) + } + + // func sendGetNews(callback: (() -> Void)? = nil) { + // let t = HotlineTransaction(type: .getNewsFile) + // self.sendTransaction(t, callback: callback) + // } + + // MARK: - Incoming + + private func processReply(_ transaction: HotlineTransaction) { + guard let replyCallbackInfo = self.transactionLog[transaction.id] else { + return + } + + self.transactionLog[transaction.id] = nil + + print("HotlineClient reply in response to \(replyCallbackInfo.0)") + +// var replyError: HotlineTransactionError? = nil + +// defer { +// let replyCallback = replyCallbackInfo.1 +// DispatchQueue.main.async { +// replyCallback?(transaction, replyError) +// } +// } + + let replyCallback = replyCallbackInfo.1 + + guard transaction.errorCode == 0 else { + let errorField: HotlineTransactionField? = transaction.getField(type: .errorText) + + print("HotlineClient 😵 \(transaction.errorCode): \(errorField?.getString() ?? "")") + + DispatchQueue.main.async { + replyCallback?(transaction, .error(transaction.errorCode, errorField?.getString())) + } + return + } + + replyCallback?(transaction, nil) + + + switch(replyCallbackInfo.0) { +// 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: [HotlineUser] = [] +// for u in transaction.getFieldList(type: .userNameWithInfo) { +// let user = u.getUser() +// newUsers[user.id] = user +// newUserList.append(user) +// } +// DispatchQueue.main.async { [weak self] in +//// self.userList = newUserList +// +// self?.delegate?.hotlineReceivedUserList(users: newUserList) +// } +// case .getMessageBoard: +// if let textField = transaction.getField(type: .data), let text = textField.getString() { +// var messages: [String] = [] +// let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ +// let matches = text.matches(of: messageBoardRegex) +// var start = text.startIndex +// +// if matches.count > 0 { +// for match in matches { +// let range = match.range +// messages.append(String(text[start..<range.lowerBound])) +// start = range.upperBound +// } +// } +// else { +// messages.append(text) +// } +// +// DispatchQueue.main.async { +// self.messageBoardMessages = messages +// } +// } + // case .getFileNameList: + // var files: [HotlineFile] = [] + // for fi in transaction.getFieldList(type: .fileNameWithInfo) { + // let file = fi.getFile() + // files.append(file) + // } + // DispatchQueue.main.async { + // self.fileList = files + // } + case .getNewsCategoryNameList: + var categories: [HotlineNewsCategory] = [] + for fi in transaction.getFieldList(type: .newsCategoryListData15) { + let c = fi.getNewsCategory() + categories.append(c) + print("CATEGORY: \(c)") + } + DispatchQueue.main.async { + self.newsCategories = categories + } + 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: + print("HotlineClient: chat \(transaction)") + 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: \(chatText)") + DispatchQueue.main.async { [weak self] in + self?.delegate?.hotlineReceivedChatMessage(message: chatText) +// self.chatMessages.append(HotlineChat(text: chatText, type: .message)) + } + } + case .notifyOfUserChange: + // print("HotlineClient: user changed") + if let usernameField = transaction.getField(type: .userName), + let username = usernameField.getString(), + let userIDField = transaction.getField(type: .userID), + let userID = userIDField.getUInt16(), + let userIconIDField = transaction.getField(type: .userIconID), + let userIconID = userIconIDField.getUInt16(), + let userFlagsField = transaction.getField(type: .userFlags), + let userFlags = userFlagsField.getUInt16() { + print("HotlineClient: User changed \(username)") + + let user = HotlineUser(id: userID, iconID: userIconID, status: userFlags, name: username) + + DispatchQueue.main.async { [weak self] in + self?.delegate?.hotlineUserChanged(user: user) + } + } + case .notifyOfUserDelete: + if let userIDField = transaction.getField(type: .userID), + let userID = userIDField.getUInt16() { + DispatchQueue.main.async { [weak self] in + self?.delegate?.hotlineUserDisconnected(userID: userID) + } + } + case .disconnectMessage: + print("HotlineClient ❌") + self.disconnect() + case .serverMessage: + if let messageField = transaction.getField(type: .data), + let message = messageField.getString() { + DispatchQueue.main.async { [weak self] in + self?.delegate?.hotlineReceivedServerMessage(message: message) + } + } + case .showAgreement: + if let _ = 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 { [weak self] in + self?.delegate?.hotlineReceivedAgreement(text: agreementText) + } + } + } + case .userAccess: + print("") + default: + print("HotlineClient: UNKNOWN transaction \(transaction.type) with \(transaction.fields.count) parameters") + print(transaction.fields) + } + } +} diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index c126d03..e4eba2d 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -496,7 +496,7 @@ func transactionTypeHasReply(_ type: HotlineTransactionType) -> Bool { enum HotlineTransactionType: UInt16 { case reply = 0 case error = 100 - case getMessages = 101 + case getMessageBoard = 101 case newMessage = 102 // Server case oldPostNews = 103 case serverMessage = 104 // Server diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 9bcf631..ed1d154 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -98,12 +98,8 @@ class HotlineTrackerClient { self.connection?.start(queue: .global()) } - private func disconnect() { - guard let c = connection else { - return - } - - c.cancel() + func disconnect() { + self.connection?.cancel() self.connection = nil } diff --git a/Hotline/HotlineApp.swift b/Hotline/HotlineApp.swift index 67397a2..e6c3ddc 100644 --- a/Hotline/HotlineApp.swift +++ b/Hotline/HotlineApp.swift @@ -19,7 +19,7 @@ struct HotlineApp: App { @State private var appState = HotlineState() @State private var hotline = HotlineClient() - private var model = Hotline(trackerClient: HotlineTrackerClient()) + private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineNewClient()) var body: some Scene { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index b83dc80..3272eb3 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,12 +1,127 @@ import SwiftUI -@Observable final class Hotline { +@Observable class FileInfo: Identifiable { + let id: UUID = UUID() + + let path: [String] + let name: String + + let type: String + let creator: String + let fileSize: UInt + + let isFolder: Bool + var children: [FileInfo]? = nil + + init(hotlineFile: HotlineFile) { + self.path = hotlineFile.path + self.name = hotlineFile.name + self.type = hotlineFile.type + self.creator = hotlineFile.creator + self.fileSize = UInt(hotlineFile.fileSize) + self.isFolder = hotlineFile.isFolder + if self.isFolder { + self.children = [] + } + } + + static func == (lhs: FileInfo, rhs: FileInfo) -> Bool { + return lhs.id == rhs.id + } +} + +enum ChatMessageType { + case agreement + case status + case message + case server +} + +struct ChatMessage: Identifiable { + let id = UUID() + + let text: String + let type: ChatMessageType + let date: Date + let username: String? + + static let parser = /^\s*([^\:]+)\:\s*(.+)/ + + init(text: String, type: ChatMessageType, date: Date) { + self.type = type + self.date = date + + if + type == .message, + let match = text.firstMatch(of: ChatMessage.parser) { + self.username = String(match.1) + self.text = String(match.2) + } + else { + self.username = nil + self.text = text + } + } +} + +struct UserStatus: OptionSet { + let rawValue: Int + + static let idle = UserStatus(rawValue: 1 << 0) + static let admin = UserStatus(rawValue: 1 << 1) +} + +struct User: Identifiable { + let id: UInt + var name: String + var iconID: UInt + var status: UserStatus + + init(hotlineUser: HotlineUser) { + var status: UserStatus = UserStatus() + if hotlineUser.isIdle { status.update(with: .idle) } + if hotlineUser.isAdmin { status.update(with: .admin) } + + self.id = UInt(hotlineUser.id) + self.name = hotlineUser.name + self.iconID = UInt(hotlineUser.iconID) + self.status = status + } + + init(id: UInt, name: String, iconID: UInt, status: UserStatus) { + self.id = id + self.name = name + self.iconID = iconID + self.status = status + } +} + +@Observable final class Hotline: HotlineNewClientDelegate { let trackerClient: HotlineTrackerClient + let client: HotlineNewClient + + var status: HotlineNewClientStatus = .disconnected + + var server: Server? = nil + var serverVersion: UInt16? = nil + var username: String = "bolt" + var iconID: UInt = 128 - init(trackerClient: HotlineTrackerClient) { + var users: [User] = [] + var chat: [ChatMessage] = [] + var messageBoard: [String] = [] + var files: [FileInfo] = [] + + // MARK: - + + init(trackerClient: HotlineTrackerClient, client: HotlineNewClient) { self.trackerClient = trackerClient + self.client = client + self.client.delegate = self } + // MARK: - + @MainActor func getServers(address: String, port: Int = Tracker.defaultPort) async -> [Server] { let fetchedServers: [HotlineServer] = await self.trackerClient.fetchServers(address: address, port: port) @@ -20,4 +135,188 @@ import SwiftUI return servers } + + @MainActor func disconnectTracker() { + self.trackerClient.disconnect() + } + + @MainActor func login(server: Server, login: String, password: String, username: String, iconID: UInt) async -> Bool { + self.server = server + self.username = username + self.iconID = iconID + + return await withCheckedContinuation { [weak self] continuation in + let _ = self?.client.login(server.address, port: UInt16(server.port), login: login, password: password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverVersion in + self?.serverVersion = serverVersion + continuation.resume(returning: (err != nil)) + } + } + } + + @MainActor func disconnect() { + self.client.disconnect() + } + + @MainActor func sendChat(_ text: String) { + self.client.sendChat(message: text, sent: nil) + } + + @MainActor func getMessageBoard() async -> [String] { + self.messageBoard = await withCheckedContinuation { [weak self] continuation in + self?.client.sendGetMessageBoard() { err, messages in + continuation.resume(returning: (err != nil ? [] : messages)) + } + } + + return self.messageBoard + } + + @MainActor func getFileList(path: [String] = []) async -> [FileInfo] { + return await withCheckedContinuation { [weak self] continuation in + self?.client.sendGetFileList(path: path, sent: { success in + if !success { + continuation.resume(returning: []) + return + } + // Failed to send? + }, reply: { [weak self] files in + let parentFile = self?.findFile(in: self?.files ?? [], at: path) + + var newFiles: [FileInfo] = [] + for f in files { + newFiles.append(FileInfo(hotlineFile: f)) + } + + if let parent = parentFile { + print("FOUND PARENT AT \(path)") + parent.children = newFiles + } + else if path.isEmpty { + print("FOUND ROOT AT \(path)") + self?.files = newFiles + } + + continuation.resume(returning: newFiles) + }) + } + } + + +// @MainActor func updateUsers() async -> [User] { +// let userList = await self.client.sendGetUserList() +// var users = [] +//// self.client.sendChat(message: text) +// +// return users +// } + + // MARK: - Hotline Delegate + + func hotlineStatusChanged(status: HotlineNewClientStatus) { + print("Hotline: Connection status changed to: \(status)") + + if status == .disconnected { + self.serverVersion = nil + self.users = [] + self.chat = [] + self.messageBoard = [] + self.files = [] + } + + self.status = status + } + + func hotlineGetUserInfo() -> (String, UInt16) { + return (self.username, UInt16(self.iconID)) + } + + func hotlineReceivedAgreement(text: String) { + self.chat.append(ChatMessage(text: text, type: .agreement, date: Date())) + } + + func hotlineReceivedServerMessage(message: String) { +// print("Hotline: received server message:\n\(message)") +// self.chat.append(ChatMessage(text: message, type: .server, date: Date())) + } + + func hotlineReceivedChatMessage(message: String) { + self.chat.append(ChatMessage(text: message, type: .message, date: Date())) + } + + func hotlineReceivedUserList(users: [HotlineUser]) { + var existingUserIDs: [UInt] = [] + var userList: [User] = [] + + print("GOT USER LIST", users) + + for u in users { + if let i = self.users.firstIndex(where: { $0.id == u.id }) { + // If a user is already in the user list we have to assume + // they changed somehow before we received the user list + // which means let's keep their existing info. + existingUserIDs.append(UInt(u.id)) + userList.append(self.users[i]) + } + else { + userList.append(User(hotlineUser: u)) + } + } + + if !existingUserIDs.isEmpty { + self.users = self.users.filter { !existingUserIDs.contains($0.id) } + } + + self.users = userList + self.users + } + + func hotlineUserChanged(user: HotlineUser) { + self.addOrUpdateHotlineUser(user) + } + + func hotlineUserDisconnected(userID: UInt16) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users.remove(at: existingUserIndex) + self.chat.append(ChatMessage(text: "\(user.name) left", type: .status, date: Date())) + } + } + + func hotlineReceivedError(message: String) { + + } + + // MARK: - Utilities + + private func addOrUpdateHotlineUser(_ user: HotlineUser) { + if let i = self.users.firstIndex(where: { $0.id == user.id }) { + print("Hotline: updating user \(self.users[i].name)") + self.users[i] = User(hotlineUser: user) + } + else { + print("Hotline: added user: \(user.name)") + self.users.append(User(hotlineUser: user)) + self.chat.append(ChatMessage(text: "\(user.name) joined", type: .status, date: Date())) + } + } + + private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { + guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } + + // var stack: [([HotlineFile], [String])] = [(self.files!, path)] + + let currentName = path[0] + + for file in filesToSearch { + if file.name == currentName { + if path.count == 1 { + return file + } + else if let subfiles = file.children { + let remainingPath = Array(path[1...]) + return self.findFile(in: subfiles, at: remainingPath) + } + } + } + + return nil + } } diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index 5a6ba80..b159a1a 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -1,6 +1,8 @@ import SwiftUI @Observable final class Server: Identifiable, Equatable { + static let defaultPort: Int = 5500 + let id: UUID = UUID() let name: String let description: String? diff --git a/Hotline/Models/Tracker.swift b/Hotline/Models/Tracker.swift index 66d7976..f2111a0 100644 --- a/Hotline/Models/Tracker.swift +++ b/Hotline/Models/Tracker.swift @@ -3,20 +3,11 @@ import SwiftUI @Observable final class Tracker { static let defaultPort: Int = 5498 - let service: HotlineTrackerClient let address: String let port: Int - var servers: [Server] = [] - init(address: String, port: Int = defaultPort, service: HotlineTrackerClient) { + init(address: String, port: Int = defaultPort) { self.address = address self.port = port - self.service = service - } - - func fetchServers() { -// self.service.fetch2(address: self.address, port: self.port) { hotlineServers in -// self.servers = servers -// } } } diff --git a/Hotline/Views/ChatView.swift b/Hotline/Views/ChatView.swift index 1b3ba52..ce3942e 100644 --- a/Hotline/Views/ChatView.swift +++ b/Hotline/Views/ChatView.swift @@ -7,7 +7,8 @@ extension View { } struct ChatView: View { - @Environment(HotlineClient.self) private var hotline +// @Environment(HotlineClient.self) private var hotline + @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme @State var input: String = "" @@ -22,7 +23,7 @@ struct ChatView: View { ScrollView { ScrollViewReader { reader in LazyVStack(alignment: .leading) { - ForEach(hotline.chatMessages) { msg in + ForEach(model.chat) { msg in if msg.type == .agreement { VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 0) { @@ -32,7 +33,7 @@ struct ChatView: View { .opacity(0.75) HStack { Spacer() - Text((hotline.server?.name ?? "") + " Server Agreement") + Text((model.server?.name ?? "") + " Server Agreement") .font(.caption) .fontWeight(.medium) .opacity(0.4) @@ -49,10 +50,21 @@ struct ChatView: View { } .padding() } + else if msg.type == .status { + HStack { + Spacer() + Text(msg.text) + .lineLimit(1) + .truncationMode(.middle) + .opacity(0.3) + Spacer() + } + .padding() + } else { HStack(alignment: .firstTextBaseline) { - if !msg.username.isEmpty { - Text("**\(msg.username):** \(msg.text)") + if let username = msg.username { + Text("**\(username):** \(msg.text)") } else { Text(msg.text) @@ -63,9 +75,9 @@ struct ChatView: View { .padding() } } - Text("").id(bottomID) + EmptyView().id(bottomID) } - .onChange(of: hotline.chatMessages.count) { + .onChange(of: model.chat.count) { withAnimation { reader.scrollTo(bottomID, anchor: .bottom) } @@ -92,14 +104,16 @@ struct ChatView: View { .lineLimit(1...5) .onSubmit { if !self.input.isEmpty { - hotline.sendChat(message: self.input) + model.sendChat(self.input) +// hotline.sendChat(message: self.input) } self.input = "" } .frame(maxWidth: .infinity) Button { if !self.input.isEmpty { - hotline.sendChat(message: self.input) + model.sendChat(self.input) +// hotline.sendChat(message: self.input) } self.input = "" } label: { @@ -114,12 +128,12 @@ struct ChatView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - Text(hotline.server?.name ?? "") + Text(model.server?.name ?? "") .font(.headline) } ToolbarItem(placement: .navigationBarLeading) { Button { - hotline.disconnect() + model.disconnect() } label: { Text(Image(systemName: "xmark.circle.fill")) .symbolRenderingMode(.hierarchical) @@ -166,5 +180,5 @@ struct ChatView: View { #Preview { ChatView() - .environment(HotlineClient()) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineNewClient())) } diff --git a/Hotline/Views/FilesView.swift b/Hotline/Views/FilesView.swift index 23cd914..11db93c 100644 --- a/Hotline/Views/FilesView.swift +++ b/Hotline/Views/FilesView.swift @@ -2,16 +2,17 @@ import SwiftUI import UniformTypeIdentifiers struct FileView: View { - @Environment(HotlineClient.self) private var hotline +// @Environment(HotlineClient.self) private var hotline + @Environment(Hotline.self) private var model: Hotline @State var expanded = false - var file: HotlineFile + var file: FileInfo var body: some View { if file.isFolder { DisclosureGroup(isExpanded: $expanded) { - ForEach(file.files!) { childFile in + ForEach(file.children!) { childFile in FileView(file: childFile) .frame(height: 44) } @@ -27,9 +28,9 @@ struct FileView: View { } } .onChange(of: expanded) { - print("EXPANDED CHANGED") - - hotline.sendGetFileList(path: file.path) + Task { + await model.getFileList(path: file.path) + } } } else { @@ -48,7 +49,7 @@ struct FileView: View { static let byteFormatter = ByteCountFormatter() - private func formattedFileSize(_ fileSize: UInt32) -> String { + private func formattedFileSize(_ fileSize: UInt) -> String { // let bcf = ByteCountFormatter() FileView.byteFormatter.allowedUnits = [.useAll] FileView.byteFormatter.countStyle = .file @@ -83,35 +84,33 @@ struct FileView: View { } struct FilesView: View { - @Environment(HotlineClient.self) private var hotline +// @Environment(HotlineClient.self) private var hotline + @Environment(Hotline.self) private var model: Hotline @State var initialLoad = false var body: some View { NavigationStack { - List(hotline.fileList) { file in -// OutlineGroup(hotline.fileList, children: \.files, expanded: $expandedFolders) { file in + List(model.files) { file in FileView(file: file) .frame(height: 44) -// } } .task { if !initialLoad { - hotline.sendGetFileList(path: []) { - initialLoad = true - } + let _ = await model.getFileList() + initialLoad = true } } .listStyle(.plain) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - Text(hotline.server?.name ?? "") + Text(model.server?.name ?? "") .font(.headline) } ToolbarItem(placement: .navigationBarLeading) { Button { - hotline.disconnect() + model.disconnect() } label: { Text(Image(systemName: "xmark.circle.fill")) .symbolRenderingMode(.hierarchical) diff --git a/Hotline/Views/MessageBoardView.swift b/Hotline/Views/MessageBoardView.swift index 04f51df..473162e 100644 --- a/Hotline/Views/MessageBoardView.swift +++ b/Hotline/Views/MessageBoardView.swift @@ -1,8 +1,9 @@ import SwiftUI struct MessageBoardView: View { - @Environment(HotlineState.self) private var appState - @Environment(HotlineClient.self) private var hotline + @Environment(Hotline.self) private var model: Hotline +// @Environment(HotlineState.self) private var appState +// @Environment(HotlineClient.self) private var hotline @State private var fetched = false @@ -11,7 +12,7 @@ struct MessageBoardView: View { NavigationStack { ScrollView { LazyVStack(alignment: .leading) { - ForEach(hotline.messageBoardMessages, id: \.self) { + ForEach(model.messageBoard, id: \.self) { Text($0) .lineLimit(100) .padding() @@ -23,23 +24,26 @@ struct MessageBoardView: View { } .task { if !fetched { - hotline.sendGetMessageBoard() { - fetched = true - } + let _ = await model.getMessageBoard() +// hotline.sendGetMessageBoard() { + fetched = true +// } } } .refreshable { - hotline.sendGetMessageBoard() + let _ = await model.getMessageBoard() +// hotline.sendGetMessageBoard() } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - Text(hotline.server?.name ?? "") + Text(model.server?.name ?? "") .font(.headline) } ToolbarItem(placement: .navigationBarLeading) { Button { - hotline.disconnect() + model.disconnect() +// hotline.disconnect() } label: { Text(Image(systemName: "xmark.circle.fill")) .symbolRenderingMode(.hierarchical) @@ -65,6 +69,7 @@ struct MessageBoardView: View { #Preview { MessageBoardView() - .environment(HotlineState()) - .environment(HotlineClient()) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineNewClient())) +// .environment(HotlineState()) +// .environment(HotlineClient()) } diff --git a/Hotline/Views/ServerView.swift b/Hotline/Views/ServerView.swift index 221156a..09ab877 100644 --- a/Hotline/Views/ServerView.swift +++ b/Hotline/Views/ServerView.swift @@ -1,28 +1,9 @@ import SwiftUI struct ServerView: View { - @Environment(HotlineClient.self) private var hotline + @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme - func connectionStatusTitle(status: HotlineClientStatus) -> String { - switch(status) { - case .disconnected: - return "Disconnected" - case .connecting: - return "Connecting" - case .connected: - return "Connected" - case .loggingIn: - return "Logging In" - case .loggedIn: - return "Logged In" - } - } - - func connectionProgress(status: HotlineClientStatus) -> Double { - return Double(status.rawValue) / Double(HotlineClientStatus.loggedIn.rawValue) - } - enum Tab { case chat, users, news, messageBoard, files } @@ -35,17 +16,19 @@ struct ServerView: View { } .tag(Tab.chat) - UserListView() + UsersView() .tabItem { Image(systemName: "person.2") } .tag(Tab.users) - NewsView() - .tabItem { - Image(systemName: "newspaper") - } - .tag(Tab.news) + if let v = model.serverVersion, v >= 150 { + NewsView() + .tabItem { + Image(systemName: "newspaper") + } + .tag(Tab.news) + } MessageBoardView() .tabItem { @@ -65,5 +48,4 @@ struct ServerView: View { #Preview { ServerView() - .environment(HotlineClient()) } diff --git a/Hotline/Views/TrackerView.swift b/Hotline/Views/TrackerView.swift index 2d2032e..2fc27c7 100644 --- a/Hotline/Views/TrackerView.swift +++ b/Hotline/Views/TrackerView.swift @@ -1,20 +1,172 @@ import SwiftUI +struct TrackerConnectView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + + @State private var server: Server? + @State private var address = "" + @State private var login = "" + @State private var password = "" + @State private var connecting = false + + func connectionStatusToProgress(status: HotlineNewClientStatus) -> Double { + switch status { + case .disconnected: + return 0.0 + case .connecting: + return 0.1 + case .connected: + return 0.25 + case .loggingIn: + return 0.5 + case .loggedIn: + return 1.0 + } + } + + + + var body: some View { + VStack(alignment: .leading) { + if !connecting { + TextField("Server Address", text: $address) + .keyboardType(.URL) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + TextField("Login", text: $login) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + SecureField("Password", text: $password) + .disableAutocorrection(true) + .textFieldStyle(.plain) + .frame(height: 48) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + } + else { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .frame(minHeight: 10) + .accentColor(colorScheme == .dark ? .white : .black) + } + + Spacer() + + HStack { + Button { + dismiss() + server = nil + model.disconnect() + } label: { + Text("Cancel") + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + 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(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) + Button { + let s = Server(name: address, description: nil, address: address, port: Server.defaultPort, users: 0) + server = s + connecting = true + Task { + let loggedIn = await model.login(server: s, login: login, password: password, username: "bolt", iconID: 128) + if !loggedIn { + connecting = false + } + } + } label: { + Text("Connect") + } + .disabled(connecting) + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + 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(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) + } + .padding() + } + .padding() + .onChange(of: model.status) { + print("MODEL STATUS CHANGED") + if model.server != nil && server != nil && model.server! == server! { + if model.status == .loggedIn { + dismiss() + } + else { + connecting = (model.status != .disconnected) + } + } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text("Connect to Server") + .font(.headline) + } + } +// .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground)) + .presentationBackground { + Color.clear + .background(Material.regular) + } + .presentationDetents([.height(300), .large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(20) + // .background(Color(uiColor: .systemGroupedBackground)) + } +} + struct TrackerView: View { // @Environment(\.modelContext) private var modelContext // @Query private var items: [Item] - @Environment(HotlineClient.self) private var hotline @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme -// @State private var tracker = Tracker(address: "hltracker.com", service: trackerService) + // @State private var tracker = Tracker(address: "hltracker.com", service: trackerService) @State private var servers: [Server] = [] @State private var selectedServer: Server? - @State var scrollOffset: CGFloat = CGFloat.zero + @State private var scrollOffset: CGFloat = CGFloat.zero @State private var initialLoadComplete = false + @State private var refreshing = false + @State private var topBarOpacity: Double = 1.0 + @State private var connectVisible = false + @State private var connectDismissed = true + @State private var serverVisible = false func shouldDisplayDescription(server: Server) -> Bool { guard let desc = server.description else { @@ -24,7 +176,7 @@ struct TrackerView: View { return desc.count > 0 && desc != server.name } - func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + func connectionStatusToProgress(status: HotlineNewClientStatus) -> Double { switch status { case .disconnected: return 0.0 @@ -60,7 +212,8 @@ struct TrackerView: View { HStack(alignment: .center) { Spacer() Button { - // hotline.disconnect() + connectVisible = true + connectDismissed = false } label: { Text(Image(systemName: "point.3.connected.trianglepath.dotted")) .symbolRenderingMode(.hierarchical) @@ -68,17 +221,22 @@ struct TrackerView: View { .font(.title2) .padding(.trailing, 16) } + .sheet(isPresented: $connectVisible) { + connectDismissed = true + } content: { + TrackerConnectView() + } } .frame(height: 40.0) } .padding() - .opacity(scrollOffset > 80 ? 0 : 1.0) -// .padding(.top, 5) - .opacity(inverseLerp(lower: -80, upper: 0, v: scrollOffset)) -// .opacity(inverseLerp(lower: 20, upper: 0, v: scrollOffset)) Spacer() } + .opacity(inverseLerp(lower: -50, upper: 0, v: scrollOffset)) + .opacity(scrollOffset > 65 ? 0.0 : 1.0) + .opacity(topBarOpacity) + .zIndex(scrollOffset > 0 ? 1 : 3) ObservableScrollView(scrollOffset: $scrollOffset) { LazyVStack(alignment: .leading) { ForEach(self.servers) { server in @@ -102,14 +260,16 @@ struct TrackerView: View { if server == selectedServer { Spacer(minLength: 16) - if hotline.connectionStatus != .disconnected && hotline.server! == server { - ProgressView(value: connectionStatusToProgress(status: hotline.connectionStatus)) + if model.status != .disconnected && model.server != nil && model.server! == server { + ProgressView(value: connectionStatusToProgress(status: model.status)) .frame(minHeight: 10) .accentColor(colorScheme == .dark ? .white : .black) } else { Button("Connect") { - hotline.connect(to: HotlineServer(address: server.address, port: UInt16(server.port), users: UInt16(server.users), name: server.name, description: server.description)) + Task { + await model.login(server: server, login: "", password: "", username: "bolt", iconID: 128) + } } .bold() .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) @@ -119,7 +279,7 @@ struct TrackerView: View { colorScheme == .dark ? LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) : - LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + 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(colorScheme == .dark ? 0.0 : 0.2) @@ -143,6 +303,7 @@ struct TrackerView: View { } .padding(EdgeInsets(top: 75, leading: 0, bottom: 0, trailing: 0)) } + .zIndex(2) .overlay { if !initialLoadComplete { VStack { @@ -153,29 +314,44 @@ struct TrackerView: View { } } .refreshable { - initialLoadComplete = true + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.1)) { + topBarOpacity = 0.0 + } + initialLoadComplete = true + } + + model.disconnectTracker() await updateServers() + + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 1.0).delay(0.75)) { + topBarOpacity = 1.0 + } + } } + } - .fullScreenCover(isPresented: Binding(get: { return hotline.connectionStatus == .loggedIn }, set: { _ in }), onDismiss: { - hotline.disconnect() + .fullScreenCover(isPresented: Binding(get: { return (connectDismissed && serverVisible) }, set: { _ in }), onDismiss: { + model.disconnect() }) { ServerView() } + .onChange(of: model.status) { + serverVisible = (model.status == .loggedIn) + } .background(Color(uiColor: UIColor.systemGroupedBackground)) .frame(maxWidth: .infinity) .task { await updateServers() initialLoadComplete = true -// tracker.fetch() } } } #Preview { TrackerView() - .environment(HotlineClient()) - .environment(HotlineState()) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineNewClient())) // .modelContainer(for: Item.self, inMemory: true) } diff --git a/Hotline/Views/UserListView.swift b/Hotline/Views/UsersView.swift index 8ea186e..0397ef7 100644 --- a/Hotline/Views/UserListView.swift +++ b/Hotline/Views/UsersView.swift @@ -1,28 +1,28 @@ import SwiftUI -struct UserListView: View { - @Environment(HotlineClient.self) private var hotline +struct UsersView: View { + @Environment(Hotline.self) private var model: Hotline var body: some View { NavigationStack { - List(hotline.userList) { u in + List(model.users) { u in Text("🤖 \(u.name)") .fontWeight(.medium) .lineLimit(1) .truncationMode(.tail) - .foregroundStyle(u.isAdmin ? Color(hex: 0xE10000) : Color.accentColor) - .opacity(u.isIdle ? 0.5 : 1.0) + .foregroundStyle(u.status.contains(.admin) ? Color(hex: 0xE10000) : Color.accentColor) + .opacity(u.status.contains(.idle) ? 0.5 : 1.0) } // .listStyle(.grouped) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - Text(hotline.server?.name ?? "") + Text(model.server?.name ?? "") .font(.headline) } ToolbarItem(placement: .navigationBarLeading) { Button { - hotline.disconnect() + model.disconnect() } label: { Text(Image(systemName: "xmark.circle.fill")) .symbolRenderingMode(.hierarchical) @@ -37,5 +37,5 @@ struct UserListView: View { #Preview { ChatView() - .environment(HotlineClient()) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineNewClient())) } |