aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2023-12-22 17:37:13 -0800
committerDustin Mierau <dustin@mierau.me>2023-12-22 17:37:13 -0800
commit4ddc0699afedae39fa27e134bec05330426f2120 (patch)
treece0936e7c537ef527c6472b746b8ef16e471c912 /Hotline
parent3d26737cccd57fcbb5289874363f030ecef77573 (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.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Hotline/HotlineClient.swift99
-rw-r--r--Hotline/Hotline/HotlineFileClient.swift175
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift16
-rw-r--r--Hotline/Models/Hotline.swift114
-rw-r--r--Hotline/Models/Server.swift2
-rw-r--r--Hotline/iOS/ChatView.swift28
-rw-r--r--Hotline/iOS/TrackerView.swift48
-rw-r--r--Hotline/macOS/ChatView.swift104
-rw-r--r--Hotline/macOS/FileImageView.swift56
-rw-r--r--Hotline/macOS/NewsView.swift30
-rw-r--r--Hotline/macOS/ServerView.swift166
-rw-r--r--Hotline/macOS/TrackerView.swift5
12 files changed, 664 insertions, 179 deletions
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"