aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2023-12-11 23:32:28 -0800
committerDustin Mierau <dustin@mierau.me>2023-12-11 23:32:28 -0800
commit18c4b8643df09be4ce52a4b110ea21ac1ad053fc (patch)
tree335d4f0a6b430b983b6f00567b91eb1ee6b16b4b /Hotline
parent84aaa9ef0a1be6986e38d7e4f58e07d3cb84979e (diff)
Move to new Hotline model for data observation and let client exist standalone.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Hotline/HotlineClient.swift4
-rw-r--r--Hotline/Hotline/HotlineNewClient.swift781
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift2
-rw-r--r--Hotline/Hotline/HotlineTrackerClient.swift8
-rw-r--r--Hotline/HotlineApp.swift2
-rw-r--r--Hotline/Models/Hotline.swift303
-rw-r--r--Hotline/Models/Server.swift2
-rw-r--r--Hotline/Models/Tracker.swift11
-rw-r--r--Hotline/Views/ChatView.swift38
-rw-r--r--Hotline/Views/FilesView.swift31
-rw-r--r--Hotline/Views/MessageBoardView.swift27
-rw-r--r--Hotline/Views/ServerView.swift36
-rw-r--r--Hotline/Views/TrackerView.swift214
-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()))
}