diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-27 23:38:04 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-27 23:38:04 +0100 |
| commit | 213710bf5bd6413c747bf126db50816ef5de5a6e (patch) | |
| tree | 912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/Models | |
| parent | f466b21dc02f78c984ba6748e703f6780a7a0db4 (diff) | |
| parent | 6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff) | |
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/Models')
| -rw-r--r-- | Hotline/Models/ApplicationState.swift | 16 | ||||
| -rw-r--r-- | Hotline/Models/Bookmark.swift | 112 | ||||
| -rw-r--r-- | Hotline/Models/ChatMessage.swift | 55 | ||||
| -rw-r--r-- | Hotline/Models/FileDetails.swift | 4 | ||||
| -rw-r--r-- | Hotline/Models/FileInfo.swift | 54 | ||||
| -rw-r--r-- | Hotline/Models/FilePreview.swift | 229 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 1137 | ||||
| -rw-r--r-- | Hotline/Models/Preferences.swift | 182 | ||||
| -rw-r--r-- | Hotline/Models/PreviewFileInfo.swift | 11 | ||||
| -rw-r--r-- | Hotline/Models/Server.swift | 66 | ||||
| -rw-r--r-- | Hotline/Models/TransferInfo.swift | 87 |
11 files changed, 388 insertions, 1565 deletions
diff --git a/Hotline/Models/ApplicationState.swift b/Hotline/Models/ApplicationState.swift deleted file mode 100644 index 3e90ed9..0000000 --- a/Hotline/Models/ApplicationState.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -@Observable -final class ApplicationState { - static let shared = ApplicationState() - - var activeHotline: Hotline? = nil - var activeServerState: ServerState? = nil - - // Frontmost server window information - var activeServerID: UUID? = nil - var activeServerBanner: NSImage? = nil - var activeServerName: String? = nil - - var cloudKitReady: Bool = false -} diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 5afc2c0..4c2a28b 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -4,7 +4,27 @@ import SwiftData enum BookmarkType: String, Codable { case tracker = "tracker" case server = "server" - case temporary = "temporary" +} + +struct BookmarkServer: Hashable, Identifiable { + let id = UUID() + let name: String? + let address: String + let port: Int + let description: String? + let users: Int + + var server: Server { + Server(name: self.name, description: self.description, address: self.address, port: self.port, users: self.users) + } + + init(server: Server) { + self.name = server.name + self.address = server.address + self.port = server.port + self.description = server.description + self.users = server.users + } } @Model @@ -25,25 +45,6 @@ final class Bookmark { @Attribute var autoconnect: Bool = false - @Attribute(.ephemeral) - var expanded: Bool = false - - @Attribute(.ephemeral) - var loading: Bool = false - - @Attribute(.ephemeral) - var serverDescription: String? = nil - - @Attribute(.ephemeral) - var serverUserCount: Int? = nil - - @Transient - var servers: [Bookmark] = [] - - func hash(into hasher: inout Hasher) { - - } - @Transient var displayAddress: String { switch self.type { @@ -54,7 +55,7 @@ final class Bookmark { return "\(self.address):\(String(self.port))" } - case .server, .temporary: + case .server: if self.port == HotlinePorts.DefaultServerPort { return self.address } else { @@ -69,23 +70,15 @@ final class Bookmark { case .tracker: return nil - case .server, .temporary: - return Server( - name: self.name, description: self.serverDescription, address: self.address, - port: self.port, login: self.login, password: self.password, autoconnect: self.autoconnect) + case .server: + return Server(name: self.name, description: nil, address: self.address, port: self.port, login: self.login, password: self.password) } } static let DefaultBookmarks: [Bookmark] = [ - Bookmark( - type: .server, name: "The Mobius Strip", address: "67.174.208.111", - port: HotlinePorts.DefaultServerPort), - Bookmark( - type: .server, name: "System 7 Today", address: "hotline.system7today.com", - port: HotlinePorts.DefaultServerPort), - Bookmark( - type: .tracker, name: "Featured Servers", address: "hltracker.com", - port: HotlinePorts.DefaultTrackerPort), + Bookmark(type: .server, name: "The Mobius Strip", address: "mobius.trtphotl.com", port: HotlinePorts.DefaultServerPort), + Bookmark(type: .server, name: "MacDomain", address: "hotline.system7today.com", port: HotlinePorts.DefaultServerPort), + Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com", port: HotlinePorts.DefaultTrackerPort) ] init( @@ -103,17 +96,6 @@ final class Bookmark { self.autoconnect = autoconnect } - init(temporaryServer server: Server) { - self.type = .temporary - - self.name = server.name ?? server.address - self.address = server.address - self.port = server.port - - self.serverDescription = server.description - self.serverUserCount = server.users - } - init?(fileData: Data, name: String? = nil) { guard fileData.count <= 2000 else { return nil @@ -264,11 +246,6 @@ final class Bookmark { } static func add(_ bookmark: Bookmark, context: ModelContext) { - guard bookmark.type != .temporary else { - print("Bookmark: Attempting to add temporary bookmark to store. Aborting.") - return - } - let existingBookmarks = Bookmark.fetchAll(context: context) // Reindex bookmarks before insert. @@ -361,38 +338,27 @@ final class Bookmark { print("Bookmark: Failed to save bookmark reordering") } } - - func fetchServers() async { + + func fetchServers() async -> [BookmarkServer] { guard self.type == .tracker else { - // self.loading = false - return - } - - DispatchQueue.main.sync { - self.loading = true + return [] } - var fetchedBookmarks: [Bookmark] = [] + var fetchedBookmarks: [BookmarkServer] = [] let client = HotlineTrackerClient() - if let fetchedServers: [HotlineServer] = try? await client.fetchServers( - address: self.address, port: self.port) - { - for fetchedServer in fetchedServers { + do { + for try await fetchedServer in client.fetchServers(address: self.address, port: self.port) { + print("FETCHED SERVER", fetchedServer) if let serverName = fetchedServer.name { - let server = Server( - name: serverName, description: fetchedServer.description, - address: fetchedServer.address, port: Int(fetchedServer.port), - users: Int(fetchedServer.users)) - fetchedBookmarks.append(Bookmark(temporaryServer: server)) + let server = Server(name: serverName, description: fetchedServer.description, address: fetchedServer.address, port: Int(fetchedServer.port), users: Int(fetchedServer.users)) + fetchedBookmarks.append(BookmarkServer(server: server)) } } + } catch { + print("Failed to fetch servers from tracker: \(error)") } - let newServers = fetchedBookmarks - DispatchQueue.main.sync { - self.servers = newServers - self.loading = false - } + return fetchedBookmarks } } diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index 36648d1..f7383ba 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -2,28 +2,71 @@ import SwiftUI enum ChatMessageType { case agreement - case status + case joined + case left case message case server + case signOut +} + +extension ChatMessageType { + var storageKey: String { + switch self { + case .agreement: + return "agreement" + case .joined: + return "joined" + case .left: + return "left" + case .message: + return "message" + case .server: + return "server" + case .signOut: + return "signOut" + } + } + + init?(storageKey: String) { + switch storageKey { + case "agreement": + self = .agreement + case "joined": + self = .joined + case "left": + self = .left + case "message": + self = .message + case "server": + self = .server + case "signOut": + self = .signOut + default: + return nil + } + } } struct ChatMessage: Identifiable { - let id = UUID() + let id: UUID let text: String let type: ChatMessageType let date: Date let username: String? - + var metadata: ChatStore.EntryMetadata? + static let parser = /^\s*([^\:]+):\s*([\s\S]+)$/ init(text: String, type: ChatMessageType, date: Date) { + self.id = UUID() self.type = type self.date = date + self.metadata = nil - if type == .message, - let match = text.firstMatch(of: ChatMessage.parser) - { + if + type == .message, + let match = text.firstMatch(of: ChatMessage.parser) { self.username = String(match.1) self.text = String(match.2) } else { diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift index dc302e8..71ddb6d 100644 --- a/Hotline/Models/FileDetails.swift +++ b/Hotline/Models/FileDetails.swift @@ -1,7 +1,7 @@ import UniformTypeIdentifiers -struct FileDetails: Identifiable { - let id = UUID() +public struct FileDetails: Identifiable { + public let id: UUID = UUID() var name: String var path: [String] var size: Int diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index 0eadd10..272dd7b 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -15,28 +15,66 @@ import UniformTypeIdentifiers let isUnavailable: Bool var isDropboxFolder: Bool { - guard self.isFolder, - (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) - || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil) - else { + guard self.isFolder else { return false } - return true + + if self.name.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if self.name.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if self.name.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false } var isAdminDropboxFolder: Bool { self.isDropboxFolder && (self.name.range(of: "admin", options: [.caseInsensitive]) != nil) } - + + var isAppBundle: Bool { + guard self.isFolder else { + return false + } + return self.name.lowercased().hasSuffix(".app") + } + var expanded: Bool = false var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension + var fileExtension = (self.name as NSString).pathExtension.lowercased() + if fileExtension.isEmpty && !self.type.isEmpty { + let type = self.type.lowercased() + if let ext = FileManager.HFSTypeToExtension[type] { + fileExtension = ext + } + } + if let fileType = UTType(filenameExtension: fileExtension) { + if fileType.canBePreviewedByQuickLook { + return true + } + if fileType.isSubtype(of: .image) { return true - } else if fileType.isSubtype(of: .text) { + } + else if fileType.isSubtype(of: .pdf) || fileExtension == "pdf" { + return true + } + else if fileType.isSubtype(of: .audio) { + return true + } + else if fileType.isSubtype(of: .video) { + return true + } + else if fileType.isSubtype(of: .text) { return true } } diff --git a/Hotline/Models/FilePreview.swift b/Hotline/Models/FilePreview.swift index bba29fa..a142823 100644 --- a/Hotline/Models/FilePreview.swift +++ b/Hotline/Models/FilePreview.swift @@ -1,118 +1,115 @@ import SwiftUI import UniformTypeIdentifiers - -enum FilePreviewState: Equatable { - case unloaded - case loading - case loaded - case failed -} - -enum FilePreviewType: Equatable { - case unknown - case image - case text -} - -@Observable -final class FilePreview: HotlineFilePreviewClientDelegate { - @ObservationIgnored let info: PreviewFileInfo - @ObservationIgnored var client: HotlineFilePreviewClient? = nil - - var state: FilePreviewState = .unloaded - var progress: Double = 0.0 - - var data: Data? = nil - - #if os(iOS) - var image: UIImage? = nil - #elseif os(macOS) - var image: NSImage? = nil - #endif - - var text: String? = nil - var styledText: NSAttributedString? = nil - - var previewType: FilePreviewType { - let fileExtension = (info.name as NSString).pathExtension - if let fileType = UTType(filenameExtension: fileExtension) { - if fileType.isSubtype(of: .image) { - return .image - } else if fileType.isSubtype(of: .text) { - return .text - } - } - return .unknown - } - - init(info: PreviewFileInfo) { - self.info = info - - self.client = HotlineFilePreviewClient( - address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size)) - self.client?.delegate = self - } - - func download() { - self.client?.start() - } - - func cancel() { - self.client?.cancel() - } - - func hotlineTransferStatusChanged( - client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, - timeRemaining: TimeInterval - ) { - print("FilePreview: Download status changed:", status) - - switch status { - case .unconnected: - state = .unloaded - progress = 0.0 - case .connecting: - state = .loading - progress = 0.0 - case .connected: - state = .loading - progress = 0.0 - case .progress(let p): - state = .loading - progress = p - case .failed(_): - state = .failed - progress = 0.0 - case .completing: - state = .loading - progress = 1.0 - case .completed: - state = .loaded - progress = 1.0 - } - } - - func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { - self.state = .loaded - self.data = data - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - case .text: - let encoding: UInt = NSString.stringEncoding( - for: data, convertedString: nil, usedLossyConversion: nil) - if encoding != 0 { - self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) - } else { - self.text = String(data: data, encoding: .utf8) - } - case .unknown: - return - } - } -} +// +//enum FilePreviewState: Equatable { +// case unloaded +// case loading +// case loaded +// case failed +//} +// +//enum FilePreviewType: Equatable { +// case unknown +// case image +// case text +//} +// +//@Observable +//final class FilePreview: HotlineFilePreviewClientDelegate { +// @ObservationIgnored let info: PreviewFileInfo +// @ObservationIgnored var client: HotlineFilePreviewClient? = nil +// +// var state: FilePreviewState = .unloaded +// var progress: Double = 0.0 +// +// var data: Data? = nil +// +// #if os(iOS) +// var image: UIImage? = nil +// #elseif os(macOS) +// var image: NSImage? = nil +// #endif +// +// var text: String? = nil +// var styledText: NSAttributedString? = nil +// +// var previewType: FilePreviewType { +// let fileExtension = (info.name as NSString).pathExtension +// if let fileType = UTType(filenameExtension: fileExtension) { +// if fileType.isSubtype(of: .image) { +// return .image +// } +// else if fileType.isSubtype(of: .text) { +// return .text +// } +// } +// return .unknown +// } +// +// init(info: PreviewFileInfo) { +// self.info = info +// +// self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size)) +// self.client?.delegate = self +// } +// +// func download() { +// self.client?.start() +// } +// +// func cancel() { +// self.client?.cancel() +// } +// +// func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) { +// print("FilePreview: Download status changed:", status) +// +// switch status { +// case .unconnected: +// state = .unloaded +// progress = 0.0 +// case .connecting: +// state = .loading +// progress = 0.0 +// case .connected: +// state = .loading +// progress = 0.0 +// case .progress(let p): +// state = .loading +// progress = p +// case .failed(_): +// state = .failed +// progress = 0.0 +// case .completing: +// state = .loading +// progress = 1.0 +// case .completed: +// state = .loaded +// progress = 1.0 +// } +// } +// +// func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { +// self.state = .loaded +// self.data = data +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// case .text: +// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) +// if encoding != 0 { +// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) +// } +// else { +// self.text = String(data: data, encoding: .utf8) +// } +// case .unknown: +// return +// } +// } +//} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift deleted file mode 100644 index 1989d46..0000000 --- a/Hotline/Models/Hotline.swift +++ /dev/null @@ -1,1137 +0,0 @@ -import SwiftUI - -@Observable -class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, - HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate -{ - let id: UUID = UUID() - let trackerClient: HotlineTrackerClient - let client: HotlineClient - - static func == (lhs: Hotline, rhs: Hotline) -> Bool { - return lhs.id == rhs.id - } - - #if os(macOS) - static func getClassicIcon(_ index: Int) -> NSImage? { - return NSImage(named: "Classic/\(index)") - } - #elseif os(iOS) - static func getClassicIcon(_ index: Int) -> UIImage? { - return UIImage(named: "Classic/\(index)") - } - #endif - - // The icon ordering here was painsakenly pulled manually - // from the original Hotline client to display the classic icons - // in the same order as the original client. - static let classicIconSet: [Int] = [ - 141, 149, 150, 151, 172, 184, 204, - 2013, 2036, 2037, 2055, 2400, 2505, 2534, - 2578, 2592, 4004, 4015, 4022, 4104, 4131, - 4134, 4136, 4169, 4183, 4197, 4240, 4247, - 128, 129, 130, 131, 132, 133, 134, - 135, 136, 137, 138, 139, 140, 142, - 143, 144, 145, 146, 147, 148, 152, - 153, 154, 155, 156, 157, 158, 159, - 160, 161, 162, 163, 164, 165, 166, - 167, 168, 169, 170, 171, 173, 174, - 175, 176, 177, 178, 179, 180, 181, - 182, 183, 185, 186, 187, 188, 189, - 190, 191, 192, 193, 194, 195, 196, - 197, 198, 199, 200, 201, 202, 203, - 205, 206, 207, 208, 209, 212, 214, - 215, 220, 233, 236, 237, 243, 244, - 277, 410, 414, 500, 666, 1250, 1251, - 1968, 1969, 2000, 2001, 2002, 2003, 2004, - 2006, 2007, 2008, 2009, 2010, 2011, 2012, - 2014, 2015, 2016, 2017, 2018, 2019, 2020, - 2021, 2022, 2023, 2024, 2025, 2026, 2027, - 2028, 2029, 2030, 2031, 2032, 2033, 2034, - 2035, 2038, 2040, 2041, 2042, 2043, 2044, - 2045, 2046, 2047, 2048, 2049, 2050, 2051, - 2052, 2053, 2054, 2056, 2057, 2058, 2059, - 2060, 2061, 2062, 2063, 2064, 2065, 2066, - 2067, 2070, 2071, 2072, 2073, 2075, 2079, - 2098, 2100, 2101, 2102, 2103, 2104, 2105, - 2106, 2107, 2108, 2109, 2110, 2112, 2113, - 2115, 2116, 2117, 2118, 2119, 2120, 2121, - 2122, 2123, 2124, 2125, 2126, 4150, 2223, - 2401, 2402, 2403, 2404, 2500, 2501, 2502, - 2503, 2504, 2506, 2507, 2528, 2529, 2530, - 2531, 2532, 2533, 2535, 2536, 2537, 2538, - 2539, 2540, 2541, 2542, 2543, 2544, 2545, - 2546, 2547, 2548, 2549, 2550, 2551, 2552, - 2553, 2554, 2555, 2556, 2557, 2558, 2559, - 2560, 2561, 2562, 2563, 2564, 2565, 2566, - 2567, 2568, 2569, 2570, 2571, 2572, 2573, - 2574, 2575, 2576, 2577, 2579, 2580, 2581, - 2582, 2583, 2584, 2585, 2586, 2587, 2588, - 2589, 2590, 2591, 2593, 2594, 2595, 2596, - 2597, 2598, 2599, 2600, 4000, 4001, 4002, - 4003, 4005, 4006, 4007, 4008, 4009, 4010, - 4011, 4012, 4013, 4014, 4016, 4017, 4018, - 4019, 4020, 4021, 4023, 4024, 4025, 4026, - 4027, 4028, 4029, 4030, 4031, 4032, 4033, - 4034, 4035, 4036, 4037, 4038, 4039, 4040, - 4041, 4042, 4043, 4044, 4045, 4046, 4047, - 4048, 4049, 4050, 4051, 4052, 4053, 4054, - 4055, 4056, 4057, 4058, 4059, 4060, 4061, - 4062, 4063, 4064, 4065, 4066, 4067, 4068, - 4069, 4070, 4071, 4072, 4073, 4074, 4075, - 4076, 4077, 4078, 4079, 4080, 4081, 4082, - 4083, 4084, 4085, 4086, 4087, 4088, 4089, - 4090, 4091, 4092, 4093, 4094, 4095, 4096, - 4097, 4098, 4099, 4100, 4101, 4102, 4103, - 4105, 4106, 4107, 4108, 4109, 4110, 4111, - 4112, 4113, 4114, 4115, 4116, 4117, 4118, - 4119, 4120, 4121, 4122, 4123, 4124, 4125, - 4126, 4127, 4128, 4129, 4130, 4132, 4133, - 4135, 4137, 4138, 4139, 4140, 4141, 4142, - 4143, 4144, 4145, 4146, 4147, 4148, 4149, - 4151, 4152, 4153, 4154, 4155, 4156, 4157, - 4158, 4159, 4160, 4161, 4162, 4163, 4164, - 4165, 4166, 4167, 4168, 4170, 4171, 4172, - 4173, 4174, 4175, 4176, 4177, 4178, 4179, - 4180, 4181, 4182, 4184, 4185, 4186, 4187, - 4188, 4189, 4190, 4191, 4192, 4193, 4194, - 4195, 4196, 4198, 4199, 4200, 4201, 4202, - 4203, 4204, 4205, 4206, 4207, 4208, 4209, - 4210, 4211, 4212, 4213, 4214, 4215, 4216, - 4217, 4218, 4219, 4220, 4221, 4222, 4223, - 4224, 4225, 4226, 4227, 4228, 4229, 4230, - 4231, 4232, 4233, 4234, 4235, 4236, 4238, - 4241, 4242, 4243, 4244, 4245, 4246, 4248, - 4249, 4250, 4251, 4252, 4253, 4254, 31337, - 6001, 6002, 6003, 6004, 6005, 6008, 6009, - 6010, 6011, 6012, 6013, 6014, 6015, 6016, - 6017, 6018, 6023, 6025, 6026, 6027, 6028, - 6029, 6030, 6031, 6032, 6033, 6034, 6035, - ] - - var status: HotlineClientStatus = .disconnected - var server: Server? { - didSet { - self.updateServerTitle() - } - } - var serverVersion: UInt16 = 123 - var serverName: String? { - didSet { - self.updateServerTitle() - } - } - var serverTitle: String = "Server" - var username: String = "guest" - var iconID: Int = 414 - var access: HotlineUserAccessOptions? - var agreed: Bool = false - var users: [User] = [] - var accounts: [HotlineAccount] = [] - var chat: [ChatMessage] = [] - var messageBoard: [String] = [] - var messageBoardLoaded: Bool = false - var files: [FileInfo] = [] - var filesLoaded: Bool = false - var news: [NewsInfo] = [] - private var newsLookup: [String: NewsInfo] = [:] - var newsLoaded: Bool = false - var accountsLoaded: Bool = false - var instantMessages: [UInt16: [InstantMessage]] = [:] - var transfers: [TransferInfo] = [] - var downloads: [HotlineTransferClient] = [] - var unreadInstantMessages: [UInt16: UInt16] = [:] - var unreadPublicChat: Bool = false - var errorDisplayed: Bool = false - var errorMessage: String? = nil - - @ObservationIgnored var bannerClient: HotlineFilePreviewClient? - #if os(macOS) - var bannerImage: NSImage? = nil - #elseif os(iOS) - var bannerImage: UIImage? = nil - #endif - - // MARK: - - - init(trackerClient: HotlineTrackerClient, client: HotlineClient) { - self.trackerClient = trackerClient - self.client = client - self.client.delegate = self - } - - // MARK: - - - @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async - -> [Server] - { - var servers: [Server] = [] - - if let fetchedServers: [HotlineServer] = try? await self.trackerClient.fetchServers( - address: tracker, port: port) - { - for s in fetchedServers { - if let serverName = s.name { - servers.append( - Server( - name: serverName, description: s.description, address: s.address, port: Int(s.port), - users: Int(s.users))) - } - } - } - - return servers - } - - @MainActor func disconnectTracker() { - self.trackerClient.close() - } - - @MainActor func login( - server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil - ) { - self.server = server - self.serverName = server.name - self.username = username - self.iconID = iconID - - self.client.login( - address: server.address, port: server.port, login: server.login, password: server.password, - username: username, iconID: UInt16(iconID) - ) { [weak self] err, serverName, serverVersion in - self?.serverVersion = serverVersion ?? 123 - if serverName != nil { - self?.serverName = serverName - } - - callback?(err == nil) - } - } - - @MainActor func sendUserInfo( - username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil - ) { - self.username = username - self.iconID = iconID - - self.client.sendSetClientUserInfo( - username: username, iconID: UInt16(iconID), options: options, autoresponse: autoresponse) - } - - @MainActor func getUserList() { - self.client.sendGetUserList() - } - - @MainActor func disconnect() { - self.client.disconnect() - self.bannerClient?.cancel() - } - - @MainActor func sendAgree() { - self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) - } - - @MainActor func sendInstantMessage(_ text: String, userID: UInt16) { - let message = InstantMessage( - direction: .outgoing, text: text.convertingLinksToMarkdown(), type: .message, date: Date()) - - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [message] - } else { - self.instantMessages[userID]!.append(message) - } - - self.client.sendInstantMessage(message: text, userID: userID) - - if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - func markPublicChatAsRead() { - self.unreadPublicChat = false - } - - func hasUnreadInstantMessages(userID: UInt16) -> Bool { - return self.unreadInstantMessages[userID] != nil - } - - func markInstantMessagesAsRead(userID: UInt16) { - self.unreadInstantMessages.removeValue(forKey: userID) - } - - @MainActor func sendChat(_ text: String, announce: Bool = false) { - self.client.sendChat(message: text, announce: announce) - } - - @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)) - } - } - - self.messageBoardLoaded = true - - return self.messageBoard - } - - @MainActor func postToMessageBoard(text: String) { - self.client.sendPostMessageBoard(text: text) - } - - @MainActor func getFileList(path: [String] = []) async -> [FileInfo] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileList(path: path) { [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)) - } - - DispatchQueue.main.async { - if let parent = parentFile { - parent.children = newFiles - } else if path.isEmpty { - self?.filesLoaded = true - self?.files = newFiles - } - - continuation.resume(returning: newFiles) - } - } - } - } - - @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async - -> String? - { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { - articleText in - // let parentNews = self?.findNews(in: self?.news ?? [], at: path) - - // var newCategories: [NewsInfo] = [] - // for category in categories { - // newCategories.append(NewsInfo(hotlineNewsCategory: category)) - // } - // - // if let parent = existingNewsItem { - // parent.children = newCategories - // } - // else if path.isEmpty { - // self?.news = newCategories - // } - - continuation.resume(returning: articleText) - } - } - - } - - @MainActor func getAccounts() async -> [HotlineAccount] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetAccounts { articles in - continuation.resume(returning: articles) - } - } - } - - @MainActor func getNewsList(at path: [String] = []) async { - return await withCheckedContinuation { [weak self] continuation in - let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path) - - // Send a categories request for bundle paths or root (empty path) - if path.isEmpty || parentNewsGroup?.type == .bundle { - print("Hotline: Requesting categories at: /\(path.joined(separator: "/"))") - - self?.client.sendGetNewsCategories(path: path) { @MainActor [weak self] categories in - // Create info for each category returned. - var newCategoryInfos: [NewsInfo] = [] - - // Transform hotline categories into NewsInfo objects. - for category in categories { - var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) - - if let lookupPath = newsCategoryInfo.lookupPath { - // Merge returned category info with existing category info. - if let existingCategoryInfo = self?.newsLookup[lookupPath] { - print("Hotline: Merging category into existing category at \(lookupPath)") - - existingCategoryInfo.count = newsCategoryInfo.count - existingCategoryInfo.name = newsCategoryInfo.name - existingCategoryInfo.path = newsCategoryInfo.path - existingCategoryInfo.categoryID = newsCategoryInfo.categoryID - newsCategoryInfo = existingCategoryInfo - } else { - print("Hotline: New category added at \(lookupPath)") - self?.newsLookup[lookupPath] = newsCategoryInfo - } - } - - newCategoryInfos.append(newsCategoryInfo) - } - - if let parent = parentNewsGroup { - parent.children = newCategoryInfos - } else if path.isEmpty { - self?.newsLoaded = true - self?.news = newCategoryInfos - } - - continuation.resume() - } - } else { - print("Hotline: Requesting articles at: /\(path.joined(separator: "/"))") - - self?.client.sendGetNewsArticles(path: path) { @MainActor [weak self] articles in - print("Hotline: Organizing news at \(path.joined(separator: "/"))") - - // Create info for each article returned. - var newArticleInfos: [NewsInfo] = [] - - for article in articles { - var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) - - if let lookupPath = newsArticleInfo.lookupPath { - // Merge returned category info with existing category info. - if let existingArticleInfo = self?.newsLookup[lookupPath] { - print("Hotline: Merging article into existing article at \(lookupPath)") - - existingArticleInfo.count = newsArticleInfo.count - existingArticleInfo.name = newsArticleInfo.name - existingArticleInfo.path = newsArticleInfo.path - existingArticleInfo.articleUsername = newsArticleInfo.articleUsername - existingArticleInfo.articleDate = newsArticleInfo.articleDate - existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors - existingArticleInfo.articleID = newsArticleInfo.articleID - newsArticleInfo = existingArticleInfo - } else { - print("Hotline: New article added at \(lookupPath)") - self?.newsLookup[lookupPath] = newsArticleInfo - } - } - - newArticleInfos.append(newsArticleInfo) - } - - let organizedNewsArticles: [NewsInfo] = self?.organizeNewsArticles(newArticleInfos) ?? [] - if let parent = parentNewsGroup { - parent.children = organizedNewsArticles - } - - continuation.resume() - } - } - } - } - - func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { - // Place articles under their parent. - var organized: [NewsInfo] = [] - for article in flatArticles { - if let parentLookupPath = article.parentArticleLookupPath, - let parentArticle = self.newsLookup[parentLookupPath] - { - // article.expanded = true - if parentArticle.children.firstIndex(of: article) == nil { - article.expanded = true - parentArticle.children.append(article) - } - } else { - organized.append(article) - } - } - - return organized - } - - @MainActor func postNewsArticle( - title: String, body: String, at path: [String], parentID: UInt32 = 0 - ) async -> Bool { - - return await withCheckedContinuation { [weak self] continuation in - guard let client = self?.client else { - continuation.resume(returning: false) - return - } - - client.postNewsArticle( - title: title, text: body, path: path, parentID: parentID, - callback: { success in - print("Hotline: News article posted? \(success)") - continuation.resume(returning: success) - }) - } - } - - // @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] { - // return await withCheckedContinuation { [weak self] continuation in - // guard let client = self?.client else { - // continuation.resume(returning: []) - // return - // } - // - // client.sendGetNewsCategories(path: path) { [weak self] categories in - // let parentNews = self?.findNews(in: self?.news ?? [], at: path) - // - // var newCategories: [NewsInfo] = [] - // for category in categories { - // let categoryInfo: NewsInfo = NewsInfo(hotlineNewsCategory: category) - // newCategories.append(categoryInfo) - // self?.newsLookup[categoryInfo.path.joined(separator: "/")] = categoryInfo - // } - // - // DispatchQueue.main.async { - // if let parent = parentNews { - // parent.children = newCategories - // } - // else if path.isEmpty { - // self?.news = newCategories - // } - // - // continuation.resume(returning: newCategories) - // } - // } - // } - // } - - @MainActor func getArticles(at path: [String]) async -> [NewsInfo] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsArticles(path: path) { articles in - continuation.resume(returning: []) - } - } - } - - @MainActor func downloadFile( - _ fileName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil - ) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0..<path.count - 1]) - } - - self.client.sendDownloadFile(name: fileName, path: fullPath) { - [weak self] - success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount - in - print("GOT DOWNLOAD REPLY:") - print("\tSUCCESS?", success) - print("\tTRANSFER SIZE: \(downloadTransferSize.debugDescription)") - print("\tFILE SIZE: \(downloadFileSize.debugDescription)") - print("\tREFERENCE NUM: \(downloadReferenceNumber.debugDescription)") - print("\tWAITING COUNT: \(downloadWaitingCount.debugDescription)") - - if let self = self, - // let server = self.server, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = downloadReferenceNumber, - let transferSize = downloadTransferSize - { - - let fileClient = HotlineFileDownloadClient( - address: address, port: UInt16(port), reference: referenceNumber, - size: UInt32(transferSize)) - // let previewClient = HotlineFilePreviewClient(server: self.server, reference: referenceNumber, size: UInt32(transferSize), type: .fileDownload) - // let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .fileDownload) - fileClient.delegate = self - self.downloads.append(fileClient) - - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) - transfer.downloadCallback = callback - self.transfers.append(transfer) - - fileClient.start() - } - } - } - - @MainActor func downloadFileTo( - url fileURL: URL, fileName: String, path: [String], - progress progressCallback: ((TransferInfo, Double) -> Void)? = nil, - complete callback: ((TransferInfo, URL) -> Void)? = nil - ) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0..<path.count - 1]) - } - - self.client.sendDownloadFile(name: fileName, path: fullPath) { - [weak self] - success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount - in - print("GOT DOWNLOAD REPLY:") - print("\tSUCCESS?", success) - print("\tTRANSFER SIZE: \(downloadTransferSize.debugDescription)") - print("\tFILE SIZE: \(downloadFileSize.debugDescription)") - print("\tREFERENCE NUM: \(downloadReferenceNumber.debugDescription)") - print("\tWAITING COUNT: \(downloadWaitingCount.debugDescription)") - - if let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = downloadReferenceNumber, - let transferSize = downloadTransferSize - { - - let fileClient = HotlineFileDownloadClient( - address: address, port: UInt16(port), reference: referenceNumber, - size: UInt32(transferSize)) - fileClient.delegate = self - self.downloads.append(fileClient) - - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) - transfer.downloadCallback = callback - transfer.progressCallback = progressCallback - self.transfers.append(transfer) - - fileClient.start(to: fileURL) - } - } - } - - @MainActor func uploadFile( - url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil - ) { - let fileName = fileURL.lastPathComponent - - guard fileURL.isFileURL, !fileName.isEmpty else { - print("NOT A FILE URL?") - return - } - - let filePath = fileURL.path(percentEncoded: false) - - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), - fileIsDirectory.boolValue == false - else { - print("FILE IS A DIRECTORY?") - return - } - - var fileSize: UInt = 0 - - // Data size - let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath) - if let sizeAttribute = fileAttributes?[.size] as? NSNumber { - print("DATA SIZE \(sizeAttribute.uintValue)") - fileSize += sizeAttribute.uintValue - } - - // Resource size - let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc") - - print("RESOURCE PATH \(resourceURL)") - let resourceAttributes = try? FileManager.default.attributesOfItem( - atPath: resourceURL.path(percentEncoded: false)) - if let sizeAttribute = resourceAttributes?[.size] as? NSNumber { - print("RESOURCE SIZE \(sizeAttribute.uintValue)") - fileSize += sizeAttribute.uintValue - } - - print("FILE SIZE? \(fileSize)") - - guard fileSize > 0 else { - print("FILE IS EMPTY??") - return - } - - print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)") - - self.client.sendUploadFile(name: fileName, path: path) { - [weak self] success, uploadReferenceNumber in - print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))") - - if let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = uploadReferenceNumber, - let fileClient = HotlineFileUploadClient( - upload: fileURL, address: address, port: UInt16(port), reference: referenceNumber) - { - - print("GOING TO UPLOAD") - - fileClient.delegate = self - self.downloads.append(fileClient) - - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize) - transfer.uploadCallback = callback - self.transfers.append(transfer) - - fileClient.start() - } - } - - } - - @MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0..<path.count - 1]) - } - - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileInfo(name: fileName, path: fullPath) { info in - continuation.resume(returning: info) - } - } - } - - @MainActor func deleteFile(_ fileName: String, path: [String]) async -> Bool { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0..<path.count - 1]) - } - - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendDeleteFile(name: fileName, path: fullPath) { success in - continuation.resume(returning: success) - } - } - } - - @MainActor func previewFile( - _ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil - ) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0..<path.count - 1]) - } - - self.client.sendDownloadFile(name: fileName, path: fullPath, preview: true) { - [weak self] - success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount - in - guard success else { - callback?(nil) - return - } - - print("GOT DOWNLOAD REPLY:") - print("SUCCESS?", success) - print("TRANSFER SIZE: \(downloadTransferSize.debugDescription)") - print("FILE SIZE: \(downloadFileSize.debugDescription)") - print("REFERENCE NUM: \(downloadReferenceNumber.debugDescription)") - print("WAITING COUNT: \(downloadWaitingCount.debugDescription)") - - var info: PreviewFileInfo? = nil - - if let address = self?.server?.address, - let port = self?.server?.port, - let referenceNumber = downloadReferenceNumber, - let transferSize = downloadTransferSize - { - - info = PreviewFileInfo( - id: referenceNumber, address: address, port: port, size: transferSize, name: fileName) - } - - callback?(info) - } - } - - @MainActor func deleteTransfer(id: UInt32) { - if let b = self.bannerClient, b.referenceNumber == id { - b.cancel() - self.bannerClient = nil - return - } - - if let i = self.transfers.firstIndex(where: { $0.id == id }) { - self.transfers.remove(at: i) - } - - if let i = self.downloads.firstIndex(where: { $0.referenceNumber == id }) { - let fileClient = self.downloads.remove(at: i) - fileClient.cancel() - } - } - - @MainActor func deleteAllTransfers() { - self.transfers = [] - - let downloads = self.downloads - self.downloads = [] - - for fileClient in downloads { - fileClient.cancel() - } - } - - @MainActor func downloadBanner(force: Bool = false) { - guard self.serverVersion >= 150 else { - return - } - - if self.bannerClient != nil || force { - self.bannerClient?.delegate = nil - self.bannerClient?.cancel() - self.bannerClient = nil - - if force { - self.bannerImage = nil - } - } - - if self.bannerImage != nil { - return - } - - self.client.sendDownloadBanner { - [weak self] success, downloadReferenceNumber, downloadTransferSize in - if !success { - return - } - - if let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = downloadReferenceNumber, - let transferSize = downloadTransferSize - { - self.bannerClient = HotlineFilePreviewClient( - address: address, port: UInt16(port), reference: referenceNumber, - size: UInt32(transferSize)) - self.bannerClient?.delegate = self - self.bannerClient?.start() - } - } - } - - // MARK: - Hotline Delegate - - @MainActor func hotlineStatusChanged(status: HotlineClientStatus) { - print("Hotline: Connection status changed to: \(status)") - - if status == .disconnected { - self.serverVersion = 123 - self.serverName = nil - self.access = nil - - self.users = [] - self.chat = [] - self.messageBoard = [] - self.messageBoardLoaded = false - self.files = [] - self.filesLoaded = false - self.news = [] - self.newsLoaded = false - - self.bannerImage = nil - if let b = self.bannerClient { - b.cancel() - self.bannerClient = nil - } - - self.deleteAllTransfers() - } else if status == .loggedIn { - if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { - SoundEffectPlayer.shared.playSoundEffect(.loggedIn) - } - } - - 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 hotlineReceivedNewsPost(message: String) { - let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ - let matches = message.matches(of: messageBoardRegex) - - if matches.count == 1 { - let range = matches[0].range - self.messageBoard.insert(String(message[message.startIndex..<range.lowerBound]), at: 0) - } else { - self.messageBoard.insert(message, at: 0) - } - - SoundEffectPlayer.shared.playSoundEffect(.newNews) - } - - func hotlineReceivedServerMessage(message: String) { - if Prefs.shared.playChatSound && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) - } - - print("Hotline: received server message:\n\(message)") - self.chat.append(ChatMessage(text: message, type: .server, date: Date())) - } - - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) { - if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { - let user = self.users[existingUserIndex] - print("Hotline: received private message from \(user.name): \(message)") - - if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound { - if self.unreadInstantMessages[userID] == nil { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) - } else { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - let instantMessage = InstantMessage( - direction: .incoming, text: message.convertingLinksToMarkdown(), type: .message, - date: Date()) - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [instantMessage] - } else { - self.instantMessages[userID]!.append(instantMessage) - } - self.unreadInstantMessages[userID] = userID - } - } - - func hotlineReceivedChatMessage(message: String) { - if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - self.chat.append(ChatMessage(text: message, type: .message, date: Date())) - self.unreadPublicChat = true - } - - func hotlineReceivedUserList(users: [HotlineUser]) { - var existingUserIDs: [UInt16] = [] - var userList: [User] = [] - - 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(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) - - if Prefs.shared.showJoinLeaveMessages { - self.chat.append(ChatMessage(text: "\(user.name) left", type: .status, date: Date())) - } - - if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogout) - } - } - } - - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) { - print("Hotline: got access options") - HotlineUserAccessOptions.printAccessOptions(options) - - self.access = options - } - - func hotlineReceivedErrorMessage(code: UInt32, message: String?) { - print("Hotline: received error message \(code)", message.debugDescription) - - self.errorDisplayed = (message != nil) // Show error if there is a message to display. - self.errorMessage = message - - if self.errorDisplayed, - Prefs.shared.playSounds && Prefs.shared.playErrorSound - { - SoundEffectPlayer.shared.playSoundEffect(.error) - } - } - - // MARK: - Hotline Transfer Delegate - - func hotlineTransferStatusChanged( - client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, - timeRemaining: TimeInterval - ) { - switch status { - case .unconnected: - break - case .connecting: - break - case .connected: - break - case .progress(let progress): - if let transfer = self.transfers.first(where: { $0.id == reference }) { - transfer.progress = progress - transfer.timeRemaining = timeRemaining - transfer.progressCallback?(transfer, progress) - } - case .failed(_): - if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { - self.downloads.remove(at: i) - } - if let transfer = self.transfers.first(where: { $0.id == reference }) { - transfer.failed = true - transfer.timeRemaining = 0.0 - } - if let b = self.bannerClient, b.referenceNumber == reference { - b.delegate = nil - self.bannerClient = nil - } - case .completing: - break - case .completed: - if let transfer = self.transfers.first(where: { $0.id == reference }) { - transfer.completed = true - transfer.timeRemaining = 0.0 - } - } - } - - func hotlineFileDownloadReceivedInfo( - client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork - ) { - if let transfer = self.transfers.first(where: { $0.id == reference }) { - transfer.title = info.name - } - } - - func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { - if let b = self.bannerClient, b.referenceNumber == reference { - #if os(macOS) - self.bannerImage = NSImage(data: data) - #elseif os(iOS) - self.bannerImage = UIImage(data: data) - #endif - } else if let i = self.transfers.firstIndex(where: { $0.id == reference }) { - let transfer = self.transfers[i] - transfer.previewCallback?(transfer, data) - self.transfers.remove(at: i) - } - - if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { - self.downloads.remove(at: i) - } - } - - func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) { - if let i = self.transfers.firstIndex(where: { $0.id == reference }) { - let transfer = self.transfers[i] - transfer.fileURL = at - transfer.downloadCallback?(transfer, at) - if Prefs.shared.playSounds && Prefs.shared.playFileTransferCompleteSound { - SoundEffectPlayer.shared.playSoundEffect(.transferComplete) - } - } - - if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { - self.downloads.remove(at: i) - } - } - - func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) { - if let i = self.transfers.firstIndex(where: { $0.id == reference }) { - let transfer = self.transfers[i] - transfer.uploadCallback?(transfer) - if Prefs.shared.playSounds && Prefs.shared.playFileTransferCompleteSound { - SoundEffectPlayer.shared.playSoundEffect(.transferComplete) - } - } - - if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { - self.downloads.remove(at: i) - } - } - - // MARK: - Utilities - - func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" - } - - private func addOrUpdateHotlineUser(_ user: HotlineUser) { - print("Hotline: users: \n\(self.users)") - 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 { - if !self.users.isEmpty { - if Prefs.shared.playSounds && Prefs.shared.playJoinSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogin) - } - } - - print("Hotline: added user: \(user.name)") - self.users.append(User(hotlineUser: user)) - if Prefs.shared.showJoinLeaveMessages { - 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 } - - 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 - } - - private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { - guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } - - for news in newsToSearch { - if news.name == currentName { - if path.count == 1 { - return news - } else if !news.children.isEmpty { - let remainingPath = Array(path[1...]) - return self.findNews(in: news.children, at: remainingPath) - } - } - } - - return nil - } - - private func findNewsArticle(id articleID: UInt32, at path: [String]) -> NewsInfo? { - guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else { - return nil - } - - return parent.children.first { child in - guard let childArticleID = child.articleID else { - return false - } - - return child.type == .article && child.articleID == childArticleID - } - } -} diff --git a/Hotline/Models/Preferences.swift b/Hotline/Models/Preferences.swift deleted file mode 100644 index bcceec5..0000000 --- a/Hotline/Models/Preferences.swift +++ /dev/null @@ -1,182 +0,0 @@ -import SwiftUI - -enum PrefsKeys: String { - case username = "username" - case userIconID = "user icon id" - case refusePrivateMessages = "refuse private messages" - case refusePrivateChat = "refuse private chat" - case enableAutomaticMessage = "enable automatic message" - case automaticMessage = "automatic message" - case playSounds = "play sounds" - case playChatSound = "play chat sound" - case playFileTransferCompleteSound = "play file transfer complete sound" - case playPrivateMessageSound = "play private message sound" - case playJoinSound = "play join sound" - case playLeaveSound = "play leave sound" - case playLoggedInSound = "play logged in sound" - case playErrorSound = "play error sound" - case playChatInvitationSound = "play chat invitation sound" - case showBannerToolbar = "show banner toolbar" - case showJoinLeaveMessages = "show join leave messages" -} - -@Observable -class Prefs { - init() { - UserDefaults.standard.register(defaults: [ - PrefsKeys.username.rawValue: "guest", - PrefsKeys.userIconID.rawValue: 191, - PrefsKeys.refusePrivateMessages.rawValue: false, - PrefsKeys.refusePrivateChat.rawValue: false, - PrefsKeys.enableAutomaticMessage.rawValue: false, - PrefsKeys.automaticMessage.rawValue: "", - PrefsKeys.playSounds.rawValue: true, - PrefsKeys.playChatSound.rawValue: true, - PrefsKeys.playFileTransferCompleteSound.rawValue: true, - PrefsKeys.playPrivateMessageSound.rawValue: true, - PrefsKeys.playJoinSound.rawValue: true, - PrefsKeys.playLeaveSound.rawValue: true, - PrefsKeys.playLoggedInSound.rawValue: true, - PrefsKeys.playErrorSound.rawValue: true, - PrefsKeys.playChatInvitationSound.rawValue: true, - PrefsKeys.showBannerToolbar.rawValue: true, - PrefsKeys.showJoinLeaveMessages.rawValue: true, - ]) - - self.username = UserDefaults.standard.string(forKey: PrefsKeys.username.rawValue)! - self.userIconID = UserDefaults.standard.integer(forKey: PrefsKeys.userIconID.rawValue) - self.refusePrivateMessages = UserDefaults.standard.bool( - forKey: PrefsKeys.refusePrivateMessages.rawValue) - self.refusePrivateChat = UserDefaults.standard.bool( - forKey: PrefsKeys.refusePrivateChat.rawValue) - self.enableAutomaticMessage = UserDefaults.standard.bool( - forKey: PrefsKeys.enableAutomaticMessage.rawValue) - self.automaticMessage = UserDefaults.standard.string( - forKey: PrefsKeys.automaticMessage.rawValue)! - self.playSounds = UserDefaults.standard.bool(forKey: PrefsKeys.playSounds.rawValue) - self.playChatSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatSound.rawValue) - self.playFileTransferCompleteSound = UserDefaults.standard.bool( - forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) - self.playPrivateMessageSound = UserDefaults.standard.bool( - forKey: PrefsKeys.playPrivateMessageSound.rawValue) - self.playJoinSound = UserDefaults.standard.bool(forKey: PrefsKeys.playJoinSound.rawValue) - self.playLeaveSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLeaveSound.rawValue) - self.playLoggedInSound = UserDefaults.standard.bool( - forKey: PrefsKeys.playLoggedInSound.rawValue) - self.playErrorSound = UserDefaults.standard.bool(forKey: PrefsKeys.playErrorSound.rawValue) - self.playChatInvitationSound = UserDefaults.standard.bool( - forKey: PrefsKeys.playChatInvitationSound.rawValue) - self.showBannerToolbar = UserDefaults.standard.bool( - forKey: PrefsKeys.showBannerToolbar.rawValue) - self.showJoinLeaveMessages = UserDefaults.standard.bool( - forKey: PrefsKeys.showJoinLeaveMessages.rawValue) - } - - public static let shared = Prefs() - - var username: String { - didSet { UserDefaults.standard.set(self.username, forKey: PrefsKeys.username.rawValue) } - } - - var userIconID: Int { - didSet { UserDefaults.standard.set(self.userIconID, forKey: PrefsKeys.userIconID.rawValue) } - } - - var refusePrivateMessages: Bool { - didSet { - UserDefaults.standard.set( - self.refusePrivateMessages, forKey: PrefsKeys.refusePrivateMessages.rawValue) - } - } - - var playSounds: Bool { - didSet { UserDefaults.standard.set(self.playSounds, forKey: PrefsKeys.playSounds.rawValue) } - } - - var playChatSound: Bool { - didSet { - UserDefaults.standard.set(self.playChatSound, forKey: PrefsKeys.playChatSound.rawValue) - } - } - - var playFileTransferCompleteSound: Bool { - didSet { - UserDefaults.standard.set( - self.playFileTransferCompleteSound, forKey: PrefsKeys.playFileTransferCompleteSound.rawValue - ) - } - } - - var playPrivateMessageSound: Bool { - didSet { - UserDefaults.standard.set( - self.playPrivateMessageSound, forKey: PrefsKeys.playPrivateMessageSound.rawValue) - } - } - - var playJoinSound: Bool { - didSet { - UserDefaults.standard.set(self.playJoinSound, forKey: PrefsKeys.playJoinSound.rawValue) - } - } - - var playLeaveSound: Bool { - didSet { - UserDefaults.standard.set(self.playLeaveSound, forKey: PrefsKeys.playLeaveSound.rawValue) - } - } - - var playLoggedInSound: Bool { - didSet { - UserDefaults.standard.set( - self.playLoggedInSound, forKey: PrefsKeys.playLoggedInSound.rawValue) - } - } - - var playErrorSound: Bool { - didSet { - UserDefaults.standard.set(self.playErrorSound, forKey: PrefsKeys.playErrorSound.rawValue) - } - } - - var playChatInvitationSound: Bool { - didSet { - UserDefaults.standard.set( - self.playChatInvitationSound, forKey: PrefsKeys.playChatInvitationSound.rawValue) - } - } - - var refusePrivateChat: Bool { - didSet { - UserDefaults.standard.set( - self.refusePrivateChat, forKey: PrefsKeys.refusePrivateChat.rawValue) - } - } - - var enableAutomaticMessage: Bool { - didSet { - UserDefaults.standard.set( - self.enableAutomaticMessage, forKey: PrefsKeys.enableAutomaticMessage.rawValue) - } - } - - var automaticMessage: String { - didSet { - UserDefaults.standard.set(self.automaticMessage, forKey: PrefsKeys.automaticMessage.rawValue) - } - } - - var showBannerToolbar: Bool { - didSet { - UserDefaults.standard.set( - self.showBannerToolbar, forKey: PrefsKeys.showBannerToolbar.rawValue) - } - } - - var showJoinLeaveMessages: Bool { - didSet { - UserDefaults.standard.set( - self.showJoinLeaveMessages, forKey: PrefsKeys.showJoinLeaveMessages.rawValue) - } - } -} diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index 06a2966..52c45a0 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -1,18 +1,15 @@ import UniformTypeIdentifiers -enum PreviewFileType: Equatable { - case unknown - case image - case text -} - struct PreviewFileInfo: Identifiable, Codable { var id: UInt32 var address: String var port: Int var size: Int var name: String - + + var type: String? = nil + var creator: String? = nil + var previewType: FilePreviewType { let fileExtension = (self.name as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index a3e3dde..bcac81f 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -9,13 +9,22 @@ struct Server: Codable { var port: Int var login: String var password: String - var autoconnect: Bool = false - - init( - name: String?, description: String?, address: String, - port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil, - password: String? = nil, autoconnect: Bool? = false - ) { + + var displayAddress: String { + if self.port == HotlinePorts.DefaultServerPort { + return self.address + } + else { + // Wrap IPv6 addresses in brackets when displaying with port + if self.address.contains(":") { + return "[\(self.address)]:\(String(self.port))" + } else { + return "\(self.address):\(String(self.port))" + } + } + } + + init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil, password: String? = nil) { self.name = name self.description = description self.address = address.lowercased() @@ -47,10 +56,49 @@ struct Server: Codable { } static func parseServerAddressAndPort(_ address: String) -> (String, Int) { - let url = URL(string: "hotline://\(address)") + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if this looks like an IPv6 address (contains colons but no port delimiter) + // IPv6 addresses can be: + // - fe80::1234 + // - [fe80::1234]:5500 (with port) + // - 2001:db8::1 + // - [2001:db8::1]:6500 (with port) + + // If it starts with [, it's bracketed IPv6 with optional port + if trimmed.hasPrefix("[") { + // Find the closing bracket + if let closeBracketIndex = trimmed.firstIndex(of: "]") { + let hostEndIndex = trimmed.index(after: closeBracketIndex) + let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closeBracketIndex]) + + // Check if there's a port after the bracket + if hostEndIndex < trimmed.endIndex && trimmed[closeBracketIndex...].contains(":") { + let portString = trimmed[trimmed.index(after: hostEndIndex)...] + if let port = Int(portString) { + return (host.lowercased(), port) + } + } + + return (host.lowercased(), HotlinePorts.DefaultServerPort) + } + } + + // Check if it's an IPv6 address without brackets + // IPv6 addresses have multiple colons, IPv4:port has only one + // Note: IPv6 may also have a zone identifier like fe80::1%en1 + let colonCount = trimmed.filter { $0 == ":" }.count + if colonCount > 1 { + // This is likely an IPv6 address without a port + // Keep it as-is, including any zone identifier (e.g., %en1 for link-local) + return (trimmed.lowercased(), HotlinePorts.DefaultServerPort) + } + + // Otherwise use URL parsing for IPv4 or hostnames + let url = URL(string: "hotline://\(trimmed)") let port = url?.port ?? HotlinePorts.DefaultServerPort let host = url?.host(percentEncoded: false) ?? "" - return (host.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), port) + return (host.lowercased(), port) } } diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index 8aacf1d..eb0ce7f 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -2,27 +2,53 @@ import SwiftUI @Observable class TransferInfo: Identifiable, Equatable, Hashable { - var id: UInt32 - + var id: UUID = UUID() + + var referenceNumber: UInt32 var title: String var size: UInt - var progress: Double = 0.0 - var timeRemaining: TimeInterval = 0.0 + + // Status var completed: Bool = false var failed: Bool = false + var cancelled: Bool = false + var done: Bool { + self.completed || self.failed || self.cancelled + } + + var progress: Double = 0.0 + var speed: Double? = nil + var timeRemaining: TimeInterval? = nil + + var isUpload: Bool = false + var isDownload: Bool { + get { !self.isUpload } + set { self.isUpload = !newValue } + } + + // Folder transfers + var isFolder: Bool = false + var folderName: String? = nil + var fileName: String? = nil - // For file based transfers (i.e. not previews) + // Server association - tracks which HotlineState this transfer belongs to + var serverID: UUID + var serverName: String? + + // For file based transfers var fileURL: URL? = nil - var progressCallback: ((TransferInfo, Double) -> Void)? = nil - var downloadCallback: ((TransferInfo, URL) -> Void)? = nil + var progressCallback: ((TransferInfo) -> Void)? = nil + var downloadCallback: ((TransferInfo) -> Void)? = nil var uploadCallback: ((TransferInfo) -> Void)? = nil var previewCallback: ((TransferInfo, Data) -> Void)? = nil - init(id: UInt32, title: String, size: UInt) { - self.id = id + init(reference: UInt32, title: String, size: UInt, serverID: UUID, serverName: String? = nil) { + self.referenceNumber = reference self.title = title self.size = size + self.serverID = serverID + self.serverName = serverName } static func == (lhs: TransferInfo, rhs: TransferInfo) -> Bool { @@ -32,4 +58,47 @@ class TransferInfo: Identifiable, Equatable, Hashable { func hash(into hasher: inout Hasher) { hasher.combine(self.id) } + +// var formatSize(_ bytes: UInt) -> String { +// let formatter = ByteCountFormatter() +// formatter.countStyle = .file +// formatter.allowedUnits = [.useKB, .useMB, .useGB] +// return formatter.string(fromByteCount: Int64(bytes)) +// } + + var displaySize: String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return formatter.string(fromByteCount: Int64(self.size)) + } + + var displaySpeed: String? { + guard let speed = self.speed else { + return nil + } + + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return "\(formatter.string(fromByteCount: Int64(speed)))/s" + } + + var displayTimeRemaining: String? { + guard let timeRemaining = self.timeRemaining else { + return nil + } + + if timeRemaining < 60 { + return "\(Int(timeRemaining))s" + } else if timeRemaining < 3600 { + let minutes = Int(timeRemaining / 60) + let secs = Int(timeRemaining.truncatingRemainder(dividingBy: 60)) + return "\(minutes)m \(secs)s" + } else { + let hours = Int(timeRemaining / 3600) + let minutes = Int((timeRemaining.truncatingRemainder(dividingBy: 3600)) / 60) + return "\(hours)h \(minutes)m" + } + } } |