aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Models
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
commit213710bf5bd6413c747bf126db50816ef5de5a6e (patch)
tree912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/Models
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/Models')
-rw-r--r--Hotline/Models/ApplicationState.swift16
-rw-r--r--Hotline/Models/Bookmark.swift112
-rw-r--r--Hotline/Models/ChatMessage.swift55
-rw-r--r--Hotline/Models/FileDetails.swift4
-rw-r--r--Hotline/Models/FileInfo.swift54
-rw-r--r--Hotline/Models/FilePreview.swift229
-rw-r--r--Hotline/Models/Hotline.swift1137
-rw-r--r--Hotline/Models/Preferences.swift182
-rw-r--r--Hotline/Models/PreviewFileInfo.swift11
-rw-r--r--Hotline/Models/Server.swift66
-rw-r--r--Hotline/Models/TransferInfo.swift87
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"
+ }
+ }
}