diff options
| author | Dustin Mierau <dustin@mierau.me> | 2023-12-22 17:37:13 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2023-12-22 17:37:13 -0800 |
| commit | 4ddc0699afedae39fa27e134bec05330426f2120 (patch) | |
| tree | ce0936e7c537ef527c6472b746b8ef16e471c912 | |
| parent | 3d26737cccd57fcbb5289874363f030ecef77573 (diff) | |
Some work to get banner images working. Some plumbing to get file transfers working though they don't yet. Moving some code away from async/await.
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 8 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 99 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineFileClient.swift | 175 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineProtocol.swift | 16 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 114 | ||||
| -rw-r--r-- | Hotline/Models/Server.swift | 2 | ||||
| -rw-r--r-- | Hotline/iOS/ChatView.swift | 28 | ||||
| -rw-r--r-- | Hotline/iOS/TrackerView.swift | 48 | ||||
| -rw-r--r-- | Hotline/macOS/ChatView.swift | 104 | ||||
| -rw-r--r-- | Hotline/macOS/FileImageView.swift | 56 | ||||
| -rw-r--r-- | Hotline/macOS/NewsView.swift | 30 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 166 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 5 |
13 files changed, 672 insertions, 179 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a77e83d..fb6cc35 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA43205D2B1D615600FC8843 /* ServerView.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; + DA5753682B33E88A00FAC277 /* HotlineFileClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5753672B33E88A00FAC277 /* HotlineFileClient.swift */; }; + DA57536A2B34A60D00FAC277 /* FileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5753692B34A60D00FAC277 /* FileImageView.swift */; }; DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6300962B24036B0034CBFD /* HotlineClient.swift */; }; DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77253E2B21176D006C5ABB /* NewsView.swift */; platformFilter = ios; }; DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7725402B21435B006C5ABB /* ObservableScrollView.swift */; }; @@ -53,6 +55,8 @@ DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = "<group>"; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = "<group>"; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; + DA5753672B33E88A00FAC277 /* HotlineFileClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileClient.swift; sourceTree = "<group>"; }; + DA5753692B34A60D00FAC277 /* FileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImageView.swift; sourceTree = "<group>"; }; DA6300962B24036B0034CBFD /* HotlineClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClient.swift; sourceTree = "<group>"; }; DA77253E2B21176D006C5ABB /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = "<group>"; }; DA7725402B21435B006C5ABB /* ObservableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableScrollView.swift; sourceTree = "<group>"; }; @@ -148,6 +152,7 @@ children = ( DA6300962B24036B0034CBFD /* HotlineClient.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, + DA5753672B33E88A00FAC277 /* HotlineFileClient.swift */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, ); path = Hotline; @@ -189,6 +194,7 @@ DAE735022B30C0BB000C56F6 /* MessageView.swift */, DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, DAE735002B2E71F2000C56F6 /* FilesView.swift */, + DA5753692B34A60D00FAC277 /* FileImageView.swift */, ); path = macOS; sourceTree = "<group>"; @@ -275,6 +281,7 @@ DADDB28D2B22B5920024040D /* Server.swift in Sources */, DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, + DA57536A2B34A60D00FAC277 /* FileImageView.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, @@ -286,6 +293,7 @@ DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */, + DA5753682B33E88A00FAC277 /* HotlineFileClient.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */, DAE734F92B2E4185000C56F6 /* ServerView.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 0d6941b..d7d4c9e 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -16,22 +16,12 @@ enum HotlineTransactionError: Error { 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 HotlineClientDelegate: AnyObject { func hotlineGetUserInfo() -> (String, UInt16) func hotlineStatusChanged(status: HotlineClientStatus) @@ -56,8 +46,6 @@ extension HotlineClientDelegate { } class HotlineClient { - // static let shared = HotlineClient() - static let handshakePacket = Data([ 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID @@ -70,6 +58,9 @@ class HotlineClient { var connectionStatus: HotlineClientStatus = .disconnected var connectCallback: ((Bool) -> Void)? + private var serverAddress: String? = nil + private var serverPort: UInt16? = nil + private var connection: NWConnection? private var transactionLog: [UInt32:(HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] = [:] @@ -80,7 +71,7 @@ class HotlineClient { // MARK: - - func login(_ address: String, port: UInt16, login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) -> Bool { + func login(_ address: String, port: UInt16, login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { print("AWAITING CONNECT") self.connect(address: address, port: port) { [weak self] success in guard success else { @@ -100,33 +91,31 @@ class HotlineClient { } print("AWAITING LOGIN") - self?.sendLogin(login: login, password: password, username: username, iconID: iconID) { [weak self] err, serverName, serverVersion in - guard err == nil else { - DispatchQueue.main.async { - callback?(err, nil, nil) - } - return - } + self?.sendLogin(login: login, password: password, username: username, iconID: iconID) { err, serverName, serverVersion in +// guard err == nil else { +// DispatchQueue.main.async { +// callback?(err, nil, nil) +// } +// return +// } print("LOGGED INTO SERVER: \(String(describing: serverName?.debugDescription)) \(serverVersion.debugDescription)") - self?.sendSetClientUserInfo(username: username, iconID: iconID) - self?.sendGetUserList() - DispatchQueue.main.async { - callback?(nil, serverName, serverVersion) + callback?(err, serverName, serverVersion) } } } } - - return false } private func connect(address: String, port: UInt16, callback: ((Bool) -> Void)?) { - let serverAddress = NWEndpoint.Host(address) - let serverPort = NWEndpoint.Port(rawValue: port)! + self.serverAddress = address + self.serverPort = port + +// let serverAddress = NWEndpoint.Host(address) +// let serverPort = NWEndpoint.Port(rawValue: port)! let tcpOptions = NWProtocolTCP.Options() tcpOptions.enableKeepalive = true @@ -136,7 +125,7 @@ class HotlineClient { self.connectCallback = callback - self.connection = NWConnection(host: serverAddress, port: serverPort, using: connectionParameters) + self.connection = NWConnection(host: NWEndpoint.Host(address), port: NWEndpoint.Port(rawValue: port)!, using: connectionParameters) self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in guard let self = self else { return @@ -429,7 +418,7 @@ class HotlineClient { t.setFieldEncodedString(type: .userPassword, val: password) t.setFieldUInt16(type: .userIconID, val: iconID) t.setFieldString(type: .userName, val: username) - t.setFieldUInt32(type: .versionNumber, val: 123) + t.setFieldUInt32(type: .versionNumber, val: 150) self.sendTransaction(t) { success in if !success { @@ -464,18 +453,23 @@ class HotlineClient { } } - func sendSetClientUserInfo(username: String, iconID: UInt16, sent: ((Bool) -> Void)? = nil) { + func sendSetClientUserInfo(username: String, iconID: UInt16, options: HotlineUserOptions = [], autoresponse: String? = nil, sent: ((Bool) -> Void)? = nil) { var t = HotlineTransaction(type: .setClientUserInfo) t.setFieldString(type: .userName, val: username) t.setFieldUInt16(type: .userIconID, val: iconID) + t.setFieldUInt16(type: .options, val: options.rawValue) + if let text = autoresponse { + t.setFieldString(type: .automaticResponse, val: text) + } + 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) + func sendAgree(username: String, iconID: UInt16, options: HotlineUserOptions, sent: ((Bool) -> Void)? = nil) { + let t = HotlineTransaction(type: .agreed) +// t.setFieldString(type: .userName, val: username) +// t.setFieldUInt16(type: .userIconID, val: iconID) +// t.setFieldUInt8(type: .options, val: options.rawValue) self.sendTransaction(t, sent: sent) } @@ -651,6 +645,34 @@ class HotlineClient { }) } + func sendDownloadBanner(sent: ((Bool) -> Void)? = nil, reply: ((UInt32?, Int?) -> Void)? = nil) { + let t = HotlineTransaction(type: .downloadBanner) + + self.sendTransaction(t, sent: sent, reply: { r, err in + if err != nil { + reply?(nil, nil) + return + } + + if let transferSizeField = r.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = r.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() { + + print("TRANSFER SIZE", transferSize) + + DispatchQueue.main.async { + reply?(referenceNumber, transferSize) + } + } + else { + DispatchQueue.main.async { + reply?(nil, nil) + } + } + }) + } + // func sendGetNews(callback: (() -> Void)? = nil) { // let t = HotlineTransaction(type: .getNewsFile) // self.sendTransaction(t, callback: callback) @@ -659,6 +681,11 @@ class HotlineClient { // MARK: - Incoming private func processReply(_ transaction: HotlineTransaction) { + if transaction.errorCode != 0 { + let errorField: HotlineTransactionField? = transaction.getField(type: .errorText) + print("HotlineClient 😵 \(transaction.errorCode): \(errorField?.getString() ?? "")") + } + guard let replyCallbackInfo = self.transactionLog[transaction.id] else { return } diff --git a/Hotline/Hotline/HotlineFileClient.swift b/Hotline/Hotline/HotlineFileClient.swift new file mode 100644 index 0000000..25323f3 --- /dev/null +++ b/Hotline/Hotline/HotlineFileClient.swift @@ -0,0 +1,175 @@ +import Foundation +import Network + +enum HotlineFileTransferType { + case banner + case file +} + +enum HotlineFileStatus: Int { + case disconnected + case connecting + case connected + case downloading + case complete +} + +@Observable +class HotlineFileClient { + var status: HotlineFileStatus = .disconnected + var progress: Double = -1.0 + + let serverAddress: NWEndpoint.Host + let serverPort: NWEndpoint.Port + let referenceNumber: UInt32 + let referenceDataSize: UInt32 + let transferType: HotlineFileTransferType + + private var connection: NWConnection? + private var fileBytes = Data() + + init(address: String, port: UInt16, reference: UInt32, size: UInt32, type: HotlineFileTransferType = .file) { + self.serverAddress = NWEndpoint.Host(address) + self.serverPort = NWEndpoint.Port(rawValue: port + 1)! + self.referenceNumber = reference + self.referenceDataSize = size + self.status = .disconnected + self.transferType = type + + if size == 0 { + self.progress = -1.0 + } + else { + self.progress = 0.0 + } + } + + func downloadToMemory(_ callback: ((Data?) -> Void)?) { + self.reset() + self.connect { data in + callback?(data) + } + } + + private func reset() { + self.progress = 0.0 + self.fileBytes = Data() + } + + private func connect(_ callback: ((Data?) -> Void)? = nil) { + self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) + self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in + switch newState { + case .ready: + self?.status = .connected + self?.sendMagic() + case .cancelled: + self?.status = .disconnected + DispatchQueue.main.async { + callback?(self?.fileBytes) + } + case .failed(let err): + print("HotlineTrackerClient: Connection error \(err)") + if self?.status == .complete { + DispatchQueue.main.async { + callback?(self?.fileBytes) + } + } + else { + self?.status = .disconnected + } + default: + return + } + } + + self.status = .connecting + self.connection?.start(queue: .global()) + } + + func disconnect() { + self.status = .disconnected + self.connection?.cancel() + self.connection = nil + } + + private func sendMagic() { + guard let c = connection else { + print("HotlineFileClient: invalid connection to send header.") + return + } + + var headerData = Data() + headerData.appendUInt32(0x48545846) // 'HTXF' + headerData.appendUInt32(self.referenceNumber) + headerData.appendUInt32(self.referenceDataSize) + headerData.appendUInt16(2) // Type + headerData.appendUInt16(0) // Reserved? + + c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in + guard let transferType = self?.transferType else { + self?.disconnect() + return + } + + if let err = error { + print("HotlineFileClient: sending header failed \(err)") + self?.disconnect() + return + } + + switch transferType { + case .banner: + self?.receiveBannerFile() + case .file: + break + } + }) + } + + private func receiveBannerFile() { + guard let c = connection else { + return + } + + self.status = .downloading + + let finalSize = Double(self.referenceDataSize) + var maxLength: Int = 65536 + + if self.referenceDataSize != 0 { + maxLength = max(Int(self.referenceDataSize) - self.fileBytes.count, 0) + } + + if maxLength == 0 { + self.status = .complete + self.progress = 1.0 + self.disconnect() + return + } + + c.receive(minimumIncompleteLength: maxLength, maximumLength: maxLength) { [weak self] (data, context, isComplete, error) in + guard let self = self else { + return + } + + if let fileData = data { + if !fileData.isEmpty { + self.fileBytes.append(fileData) + if finalSize > 0 { + self.progress = min(min(Double(self.fileBytes.count) / finalSize, 1.0), 0.0) + } + } + } + + if isComplete { + self.status = .complete + self.progress = 1.0 + self.disconnect() + } + else { + self.receiveBannerFile() + } + } + } +} diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 065fec0..8cbbeb5 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -1,5 +1,15 @@ import Foundation +struct HotlineUserOptions: OptionSet { + let rawValue: UInt16 + + static let none: HotlineUserOptions = [] + + static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0) + static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1) + static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2) +} + struct HotlineUserAccessOptions: OptionSet { let rawValue: UInt64 @@ -142,6 +152,10 @@ struct HotlineNewsList: Identifiable { print("TITLE: \(title)") print("POSTER: \(poster)") + if flavorCount > 1 { + print("MORE THAN ONE FLAVOR!!") + } + for _ in 0..<Int(flavorCount) { let flavorLength = data.readUInt8(at: baseIndex)! baseIndex += 1 @@ -422,7 +436,7 @@ struct HotlineTransactionField { } func getInteger() -> Int? { - switch(self.data.count) { + switch(self.dataSize) { case 1: if let val = self.getUInt8() { return Int(val) diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index f9ae41d..fa0128c 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -25,6 +25,7 @@ import SwiftUI var username: String = "bolt" var iconID: UInt = 128 var access: HotlineUserAccessOptions? + var agreed: Bool = false var users: [User] = [] var chat: [ChatMessage] = [] @@ -35,6 +36,16 @@ import SwiftUI var news: [NewsInfo] = [] var newsLoaded: Bool = false + var downloads: [HotlineFileClient] = [] + + @ObservationIgnored var bannerClient: HotlineFileClient? + #if os(macOS) + var bannerImage: NSImage? = nil + #elseif os(iOS) + var bannerImage: UIImage? = nil + #endif + + // MARK: - init(trackerClient: HotlineTrackerClient, client: HotlineClient) { @@ -63,25 +74,44 @@ import SwiftUI self.trackerClient.disconnect() } - @MainActor func login(server: Server, login: String, password: String, username: String, iconID: UInt) async -> Bool { + @MainActor func login(server: Server, login: String, password: String, username: String, iconID: UInt, callback: ((Bool) -> Void)? = nil) { self.server = server self.serverName = server.name 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, serverName, serverVersion in - self?.serverVersion = serverVersion - if serverName != nil { - self?.serverName = serverName - } - continuation.resume(returning: (err != nil)) + self.client.login(server.address, port: UInt16(server.port), login: login, password: password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in + self?.serverVersion = serverVersion + if serverName != nil { + self?.serverName = serverName } + + callback?(err == nil) + } + } + + @MainActor func sendUserInfo(username: String, iconID: UInt, options: HotlineUserOptions = [], autoresponse: String? = nil, callback: ((Bool) -> Void)? = nil) { + self.username = username + self.iconID = iconID + + self.client.sendSetClientUserInfo(username: username, iconID: UInt16(iconID), options: options, autoresponse: autoresponse) { success in + callback?(success) + } + } + + @MainActor func getUserList(callback: ((Bool) -> Void)? = nil) { + self.client.sendGetUserList() { success in + callback?(success) } } @MainActor func disconnect() { self.client.disconnect() + self.bannerClient?.disconnect() + } + + @MainActor func sendAgree() { + self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) } @MainActor func sendChat(_ text: String) { @@ -288,15 +318,59 @@ import SwiftUI }) } } - -// @MainActor func updateUsers() async -> [User] { -// let userList = await self.client.sendGetUserList() -// var users = [] -//// self.client.sendChat(message: text) -// -// return users -// } + func downloadFile(path: [String], callback: ((Bool) -> Void)?) { + + } + + @MainActor func downloadBanner(force: Bool = false, callback: ((Bool) -> Void)?) { + if let b = self.bannerClient { + b.disconnect() + self.bannerClient = nil + } + + if force { + self.bannerImage = nil + } + + if self.bannerImage != nil { + callback?(true) + return + } + + self.client.sendDownloadBanner(sent: { success in + if !success { + print("FAIL BANNER") + callback?(false) + return + } + }, reply: { [weak self] downloadReferenceNumber, downloadTransferSize in + if + let self = self, + let address = self.server?.address, + let port = self.server?.port, + let referenceNumber = downloadReferenceNumber, + let transferSize = downloadTransferSize { + self.bannerClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .banner) + self.bannerClient?.downloadToMemory({ [weak self] data in + if let b = self?.bannerClient { + b.disconnect() + self?.bannerClient = nil + } + + if data != nil { + #if os(macOS) + self?.bannerImage = NSImage(data: data!) + #elseif os(iOS) + self?.bannerImage = UIImage(data: data!) + #endif + } + + callback?(data != nil) + }) + } + }) + } // MARK: - Hotline Delegate @@ -307,6 +381,7 @@ import SwiftUI self.serverVersion = nil self.serverName = nil self.access = nil + self.users = [] self.chat = [] self.messageBoard = [] @@ -315,6 +390,13 @@ import SwiftUI self.filesLoaded = false self.news = [] self.newsLoaded = false + + self.bannerImage = nil + + if let b = self.bannerClient { + b.disconnect() + self.bannerClient = nil + } } self.status = status diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index 3d0569f..b39f1c4 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -1,6 +1,6 @@ import SwiftUI -@Observable final class Server: Identifiable, Equatable, Hashable, Codable { +struct Server: Identifiable, Equatable, Hashable, Codable { static let defaultPort: Int = 5500 let id: UUID diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index 08b6a75..e56f3f1 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -24,14 +24,28 @@ struct ChatView: View { LazyVStack(alignment: .leading) { ForEach(model.chat) { msg in if msg.type == .agreement { - VStack(alignment: .leading) { - Text(msg.text) - .textSelection(.enabled) - .padding() - .opacity(0.75) + + VStack(alignment: .center) { + FileImageView() + .environment(self.model) + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 5)) + + VStack(alignment: .leading) { + HStack { + Text(msg.text) + .textSelection(.enabled) + .font(.system(size: 12)) + .fontDesign(.monospaced) + .lineSpacing(2) + .padding() + Spacer() + } + } + .background(colorScheme == .dark ? Color(white: 0.1) : Color(white: 0.96)) + .frame(maxWidth: .infinity) + .cornerRadius(5) } - .background(colorScheme == .dark ? Color(white: 0.1) : Color(white: 0.96)) - .cornerRadius(16) .frame(maxWidth: .infinity) .padding() } diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift index 6204fd2..3339de4 100644 --- a/Hotline/iOS/TrackerView.swift +++ b/Hotline/iOS/TrackerView.swift @@ -9,7 +9,7 @@ struct TrackerConnectView: View { @State private var address = "" @State private var login = "" @State private var password = "" - @State private var connecting = false +// @State private var connecting = false func connectionStatusToProgress(status: HotlineClientStatus) -> Double { switch status { @@ -28,7 +28,7 @@ struct TrackerConnectView: View { var body: some View { VStack(alignment: .leading) { - if !connecting { + if self.model.status == .disconnected { TextField("Server Address", text: $address) .keyboardType(.URL) .disableAutocorrection(true) @@ -88,17 +88,19 @@ struct TrackerConnectView: View { Button { let s = Server(name: nil, 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 + self.model.login(server: s, login: login, password: password, username: "bolt", iconID: 128) { success in + if !success { + print("FAILED LOGIN??") + } + else { + self.model.sendUserInfo(username: "bolt", iconID: 128) + self.model.getUserList() } } } label: { Text("Connect") } - .disabled(connecting) + .disabled(self.model.status != .disconnected) .bold() .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) .frame(maxWidth: .infinity) @@ -123,9 +125,9 @@ struct TrackerConnectView: View { if model.status == .loggedIn { dismiss() } - else { - connecting = (model.status != .disconnected) - } +// else { +// connecting = (model.status != .disconnected) +// } } } .toolbar { @@ -290,8 +292,14 @@ struct TrackerView: View { } else { Button("Connect") { - Task { - await model.login(server: server, login: "", password: "", username: "bolt", iconID: 128) + self.model.login(server: server, login: "", password: "", username: "bolt", iconID: 128) { success in + if !success { + print("FAILED LOGIN??") + } + else { + self.model.sendUserInfo(username: "bolt", iconID: 128) + self.model.getUserList() + } } } .bold() @@ -380,9 +388,17 @@ struct TrackerView: View { let password = url.password(percentEncoded: false) ?? "" let port = url.port ?? Server.defaultPort - Task { - model.disconnect() - let _ = await model.login(server: Server(name: nil, description: nil, address: address, port: port, users: 0), login: login, password: password, username: "bolt", iconID: 128) + self.model.disconnect() + + let tempServer = Server(name: nil, description: nil, address: address, port: port, users: 0) + self.model.login(server: tempServer, login: "", password: "", username: "bolt", iconID: 128) { success in + if !success { + print("FAILED LOGIN??") + } + else { + self.model.sendUserInfo(username: "bolt", iconID: 128) + self.model.getUserList() + } } // TODO: Find a better way to show login status when trying to connect outside of the Tracker server list. Perhaps this opens the connect sheet prefilled. diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 294f13b..f16ac8b 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -7,6 +7,7 @@ enum FocusedField: Int, Hashable { struct ChatView: View { @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss @State var input: String = "" @State private var scrollPos: Int? @@ -26,26 +27,56 @@ struct ChatView: View { GeometryReader { gm in ScrollView(.vertical) { LazyVStack(alignment: .leading) { + ForEach(model.chat) { msg in // MARK: Agreement if msg.type == .agreement { - if !msg.text.isEmpty { - HStack { - VStack(alignment: .leading) { - Text(msg.text) - .textSelection(.enabled) - .lineSpacing(4) - .padding(32) - .opacity(0.75) + VStack(alignment: .center) { + + FileImageView() + .environment(self.model) + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(spacing: 0) { + ScrollView(.vertical) { + HStack { + Text(msg.text) + .font(.system(size: 12)) + .fontDesign(.monospaced) + .textSelection(.enabled) + Spacer() + } + .padding(16) } - .frame(minWidth: 150, maxWidth: 450, alignment: .center) - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow).cornerRadius(24)) - .padding() + .scrollBounceBehavior(.basedOnSize) + .frame(maxHeight: 325) + +// Divider() +// +// HStack { +// Button("Disagree") { +// dismiss() +// } +// .controlSize(.large) +// +// Spacer() +// +// Button("Agree") { +// model.sendAgree() +// } +// .controlSize(.large) +// .keyboardShortcut(.defaultAction) +// } +// .padding(16) } - .frame(maxWidth: .infinity) - .padding() + .background(Color(white: colorScheme == .light ? 0.0 : 1.0).opacity(0.05)) + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8)) } + .frame(maxWidth: .infinity) + .padding() } // MARK: Status else if msg.type == .status { @@ -105,7 +136,7 @@ struct ChatView: View { // MARK: Input Divider Divider() - .padding(.bottom, 0) + .padding([.top, .bottom], 0) // MARK: Input Bar HStack(alignment: .lastTextBaseline, spacing: 0) { @@ -141,52 +172,11 @@ struct ChatView: View { .onTapGesture { focusedField = .chatInput } - - -// .overlay(alignment: .bottom) { -// // MARK: Input Bar -// VStack(alignment: .leading, spacing: 0) { -// -// } -// } - // } - - // VStack(alignment: .center) { - // Spacer() - - - // .onKeyPress(keys: [.return]) { event in - // print(event) - // guard - // event.phase == .down, - // event.key == .return else { - // return .ignored - // } - // - // if event.modifiers.contains(.shift) { - // print("TRYING TO ADD NEW LINE") - // self.input += "\n" - // return .handled - // } - // - // if !self.input.isEmpty { - // model.sendChat(self.input) - // } - // self.input = "" - // - // return .handled - // } - // .onSubmit { - // if !self.input.isEmpty { - // model.sendChat(self.input) - // } - // self.input = "" - // } - } } + .navigationTitle(self.model.serverTitle) + .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") .background(Color(nsColor: .textBackgroundColor)) - // } } } diff --git a/Hotline/macOS/FileImageView.swift b/Hotline/macOS/FileImageView.swift new file mode 100644 index 0000000..867fb45 --- /dev/null +++ b/Hotline/macOS/FileImageView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +enum FileImageViewStatus { + case notloaded + case loading + case loaded +} + +enum FileImageType { + case banner + case file +} + +struct FileImageView: View { + @Environment(Hotline.self) private var model: Hotline + @State var status: FileImageViewStatus = .notloaded + + @MainActor func startLoading() { + if self.status != .notloaded { + return + } + + self.status = .loading + self.model.downloadBanner { success in + if success { + self.status = .loaded + } + else { + self.status = .notloaded + } + } + } + + var body: some View { + HStack(spacing: 0) { + if let img = self.model.bannerImage { + #if os(macOS) + Image(nsImage: img) + .resizable() + .scaledToFit() + #elseif os(iOS) + Image(uiImage: img) + .resizable() + .scaledToFit() + #endif + } + } + .task { + self.startLoading() + } + } +} + +#Preview { + FileImageView() +} diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift index 33cc03e..45bae68 100644 --- a/Hotline/macOS/NewsView.swift +++ b/Hotline/macOS/NewsView.swift @@ -220,7 +220,7 @@ struct NewsView: View { .background(Color(nsColor: .textBackgroundColor)) } .toolbar { - ToolbarItem(placement:.primaryAction) { + ToolbarItem(placement: .primaryAction) { Button { } label: { @@ -228,13 +228,39 @@ struct NewsView: View { } } - ToolbarItem(placement:.primaryAction) { + ToolbarItem(placement: .primaryAction) { Button { } label: { Image(systemName: "arrowshape.turn.up.left") } } + +// if let bannerImage = model.bannerImage { +// ToolbarItem(placement: .primaryAction) { +//// HStack { +// bannerImage +// .resizable() +// .aspectRatio(contentMode: .fit) +// .clipped() +// .frame(minHeight: 60, alignment: .topLeading +// ) +// .clipped() +// } +// .frame(maxWidth: .infinity) +// .tag(MenuItem(name: "title", image: "", type: .banner)) +// .padding(.bottom, 16) + + // VStack(spacing: 0) { + // bannerImage + // .resizable() + // .aspectRatio(contentMode: .fit) + // .frame(maxWidth: .infinity, minHeight: 60, alignment: .topLeading) + // .clipped() + // } + // .frame(maxWidth: .infinity) +// } +// } } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index d858c6e..da5716f 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -47,9 +47,51 @@ struct ListItemView: View { .scaledToFit() .frame(width: 18, height: 18) Text(title) -// Spacer() } - //.tag(item.tag) + } +} + +struct AgreementView: View { + @Environment(\.dismiss) var dismiss + + let text: String + let disagree: (() -> Void)? + let agree: (() -> Void)? + + var body: some View { + VStack(spacing: 0) { + ScrollView(.vertical) { + Text(text) + .padding() + .font(.system(size: 13)) + .fontDesign(.monospaced) + .textSelection(.enabled) + .lineSpacing(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + Divider() + HStack(spacing: 8) { + Button("Disagree", action: { + dismiss() + disagree?() + }) + .controlSize(.large) + .frame(width: 80) + .keyboardShortcut(.cancelAction) + + Button("Agree", action: { + dismiss() + agree?() + }) + .controlSize(.large) + .frame(width: 80) + .keyboardShortcut(.defaultAction) + } + .frame(maxWidth: .infinity) + .padding(16) + } + .frame(width: 500, height: 500) } } @@ -70,45 +112,51 @@ private func connectionStatusToProgress(status: HotlineClientStatus) -> Double { struct ServerView: View { @Environment(Hotline.self) private var model: Hotline - @State private var selectedCategoryId: MenuItem.ID? @Environment(\.dismiss) var dismiss - let server: Server - + @State private var agreementShown: Bool = false @State private var selection: MenuItem? = ServerView.menuItems.first + + let server: Server + static var menuItems = [ MenuItem(name: "Chat", image: "bubble", type: .chat), MenuItem(name: "News", image: "newspaper", type: .news, serverVersion: 150), MenuItem(name: "Board", image: "note.text", type: .messageBoard), MenuItem(name: "Files", image: "folder", type: .files), - MenuItem(name: "Tasks", image: "arrow.up.circle", type: .tasks), +// MenuItem(name: "Tasks", image: "arrow.up.circle", type: .tasks), ] - - -// @State private var selection: String? - var body: some View { NavigationSplitView { List(selection: $selection) { - - HStack { - Text(server.name ?? "") - .fontWeight(.medium) - .lineLimit(2) - .font(.title3) - .multilineTextAlignment(.center) - .padding() - } - .selectionDisabled() - .frame(maxWidth: .infinity, minHeight: 60) - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow).cornerRadius(16)) -// .background(.white.opacity(0.2)) - .cornerRadius(10) -// .shadow(color: .black.opacity(0.1), radius: 3, y: 2) - .tag(MenuItem(name: "title", image: "", type: .banner)) - .padding(.bottom, 16) + +// VStack(spacing: 0) { +// bannerImage +// .resizable() +// .aspectRatio(contentMode: .fit) +// .frame(maxWidth: .infinity, minHeight: 60, alignment: .topLeading) +// .clipped() +// } +// .frame(maxWidth: .infinity) +// } +// else { +// HStack { +// Text(server.name ?? "") +// .fontWeight(.medium) +// .lineLimit(2) +// .font(.title3) +// .multilineTextAlignment(.center) +// .padding() +// } +// .selectionDisabled() +// .frame(maxWidth: .infinity, minHeight: 60) +// .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow).cornerRadius(16)) +// .cornerRadius(10) +// .tag(MenuItem(name: "title", image: "", type: .banner)) +// .padding(.bottom, 16) +// } if model.status != .loggedIn { HStack { @@ -121,17 +169,19 @@ struct ServerView: View { } if model.status == .loggedIn { - ForEach(ServerView.menuItems) { menuItem in - if let minServerVersion = menuItem.serverVersion { - if let v = model.serverVersion, v >= minServerVersion { + Section(model.serverTitle) { + ForEach(ServerView.menuItems) { menuItem in + if let minServerVersion = menuItem.serverVersion { + if let v = model.serverVersion, v >= minServerVersion { + ListItemView(icon: menuItem.image, title: menuItem.name) + .tag(menuItem) + } + } + else { ListItemView(icon: menuItem.image, title: menuItem.name) .tag(menuItem) } } - else { - ListItemView(icon: menuItem.image, title: menuItem.name) - .tag(menuItem) - } } if model.users.count > 0 { @@ -171,42 +221,76 @@ struct ServerView: View { switch selection.type { case .banner: EmptyView() - case .progress - : + case .progress: EmptyView() case .chat: ChatView() + .navigationTitle(self.model.serverTitle) + .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") case .news: NewsView() + .navigationTitle(self.model.serverTitle) + .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") case .messageBoard: MessageBoardView() + .navigationTitle(self.model.serverTitle) + .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") case .files: FilesView() + .navigationTitle(self.model.serverTitle) + .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") case .tasks: EmptyView() case .user: if let selectionUserID = selection.userID { MessageView(userID: selectionUserID) + .navigationTitle(self.model.serverTitle) + .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") } } } } .navigationTitle("") .onAppear { - Task { - await model.login(server: self.server, login: "", password: "", username: "bolt", iconID: 128) + self.model.login(server: self.server, login: "", password: "", username: "bolt", iconID: 128) { success in + if !success { + print("FAILED LOGIN??") + } + else { + self.model.sendUserInfo(username: "bolt", iconID: 128) + print("GETTING USER LIST????!") + self.model.getUserList() + } } } .onDisappear { - Task { - model.disconnect() - } + self.model.disconnect() } .onChange(of: model.status) { if model.status == .disconnected { dismiss() } } +// .onChange(of: model.agreement) { +// if model.agreement != nil { +// agreementShown = true +// } +// } +// .sheet(isPresented: $agreementShown, onDismiss: { +// if model.status == .disconnected { +// dismiss() +// } +// }, content: { +// if let text = model.agreement { +// AgreementView(text: text, disagree: { +// self.model.disconnect() +// print("DISAGREE") +// }, agree: { +// print("AGREE?") +// }) +//// .frame(minWidth: 300, maxWidth: 500, minHeight: 300) +// } +// }) } } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index ca1595d..874bcc2 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -215,10 +215,11 @@ struct TrackerView: View { // @AppStorage("servers", store: .standard) var bookmarks: [TrackerBookmark] = [ - TrackerBookmark(type: .server, name: "Bob Kiwi's House", address: "73.132.92.104"), + TrackerBookmark(type: .server, name: "RetroMac", address: "82.32.130.173"), TrackerBookmark(type: .server, name: "System 7 Today", address: "158.174.146.146"), + TrackerBookmark(type: .server, name: "Bob Kiwi's House", address: "73.132.92.104"), TrackerBookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com"), - TrackerBookmark(type: .tracker, name: "Ubersoft", address: "hotline.ubersoft.org"), + TrackerBookmark(type: .tracker, name: "Agent79", address: "tracked.agent79.org"), TrackerBookmark(type: .tracker, name: "Preterhuman", address: "tracker.preterhuman.net"), // "hltracker.com" |