aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Models
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-07 10:39:48 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-07 10:39:48 -0800
commit39f51fd902bb34272c78ffdfb872c22095382f70 (patch)
treefee51effb6431aeb5464931052ecdd0df32c6792 /Hotline/Models
parent87f08cf60a5d7c1cf618463916cbac4dab88e0f8 (diff)
Further cleanup of previous refactor. Removing old view model and NetSocket. Added some empty states for newsgroups and message board.
Diffstat (limited to 'Hotline/Models')
-rw-r--r--Hotline/Models/Hotline.swift1876
-rw-r--r--Hotline/Models/HotlineState.swift2468
2 files changed, 0 insertions, 4344 deletions
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift
deleted file mode 100644
index 0246fbb..0000000
--- a/Hotline/Models/Hotline.swift
+++ /dev/null
@@ -1,1876 +0,0 @@
-import SwiftUI
-
-struct FileSearchConfig: Equatable {
- /// Number of folders we process before we start applying delay backoff.
- var initialBurstCount: Int = 15
- /// Base delay applied between folder requests during the backoff phase.
- var initialDelay: TimeInterval = 0.02
- /// Multiplier used to increase the delay after each processed folder in backoff.
- var backoffMultiplier: Double = 1.1
- /// Maximum delay cap so searches don't stall out during long walks.
- var maxDelay: TimeInterval = 1.0
- /// Maximum recursion depth allowed during file search.
- var maxDepth: Int = 40
- /// Limit for repeated folder loops (guards against circular server listings).
- var loopRepetitionLimit: Int = 4
- /// Number of child folders that get prioritized after a matching parent is found.
- var hotBurstLimit: Int = 2
- /// Maximum age, in seconds, that a cached folder listing is treated as fresh.
- var cacheTTL: TimeInterval = 60 * 15
- /// Upper bound on the number of folder listings retained in the cache.
- var maxCachedFolders: Int = 1024 * 3
-}
-
-enum FileSearchStatus: Equatable {
- case idle
- case searching(processed: Int, pending: Int)
- case completed(processed: Int)
- case cancelled(processed: Int)
- case failed(String)
-
- var isActive: Bool {
- if case .searching = self {
- return true
- }
- return false
- }
-}
-
-@Observable
-class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate, HotlineFolderDownloadClientDelegate {
- 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 chatInput: String = ""
- var messageBoard: [String] = []
- var messageBoardLoaded: Bool = false
- var files: [FileInfo] = []
- var filesLoaded: Bool = false
- var fileSearchResults: [FileInfo] = []
- var fileSearchStatus: FileSearchStatus = .idle
- var fileSearchQuery: String = ""
- var fileSearchConfig = FileSearchConfig()
- var fileSearchScannedFolders: Int = 0
- var fileSearchCurrentPath: [String]? = nil
- @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil
- @ObservationIgnored private var fileSearchResultKeys: Set<String> = []
- private struct FileListCacheEntry {
- let files: [FileInfo]
- let timestamp: Date
- }
- @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:]
- 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?
- @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey?
- @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey?
- @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol?
- @ObservationIgnored private var lastPersistedMessageType: ChatMessageType?
- #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
-
- self.chatHistoryObserver = NotificationCenter.default.addObserver(forName: ChatStore.historyClearedNotification, object: nil, queue: .main) { [weak self] _ in
- self?.handleChatHistoryCleared()
- }
- }
-
- // MARK: -
-
- @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async -> [Server] {
- var servers: [Server] = []
- print("Hotline.getServerList: Starting fetch from \(tracker):\(port)")
-
- do {
- for try await hotlineServer in self.trackerClient.fetchServers(address: tracker, port: port) {
- if let serverName = hotlineServer.name {
- servers.append(Server(
- name: serverName,
- description: hotlineServer.description,
- address: hotlineServer.address,
- port: Int(hotlineServer.port),
- users: Int(hotlineServer.users)
- ))
- if servers.count % 10 == 0 {
- print("Hotline.getServerList: Collected \(servers.count) servers so far...")
- }
- }
- }
- } catch {
- print("Hotline.getServerList: Error - \(error)")
- }
-
- print("Hotline.getServerList: Returning \(servers.count) servers")
- return servers
- }
-
- @MainActor func disconnectTracker() {
- // No-op: HotlineTrackerClient now uses async/await and manages
- // connections internally. Each fetchServers() call opens and closes
- // its own connection automatically.
- }
-
- @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
-
- let key = sessionKey(for: server)
- self.chatSessionKey = key
- self.restoredChatSessionKey = nil
- self.lastPersistedMessageType = nil
- self.chat = []
- self.restoreChatHistory(for: key)
-
- 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()
- self.fileSearchSession?.cancel()
- self.fileSearchSession = nil
- self.resetFileSearchState()
- }
-
- deinit {
- if let observer = chatHistoryObserver {
- NotificationCenter.default.removeObserver(observer)
- }
- }
-
- @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] = [], suppressErrors: Bool = false, preferCache: Bool = false) async -> [FileInfo] {
- if preferCache, let cached = cachedFileList(for: path, ttl: fileSearchConfig.cacheTTL, allowStale: false) {
- return cached.items
- }
-
- return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [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
- }
-
- self?.storeFileListInCache(newFiles, for: path)
- continuation.resume(returning: newFiles)
- }
- }
- }
- }
-
- @MainActor func startFileSearch(query: String) {
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- cancelFileSearch()
- return
- }
-
- fileSearchSession?.cancel()
- resetFileSearchState()
- fileSearchQuery = trimmed
- fileSearchStatus = .searching(processed: 0, pending: 0)
- fileSearchScannedFolders = 0
- fileSearchCurrentPath = []
-
- let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig)
- fileSearchSession = session
-
- Task { await session.start() }
- }
-
- @MainActor func cancelFileSearch(clearResults: Bool = true) {
- guard let session = fileSearchSession else {
- if clearResults {
- resetFileSearchState()
- } else if !fileSearchResults.isEmpty {
- fileSearchStatus = .cancelled(processed: fileSearchScannedFolders)
- fileSearchCurrentPath = nil
- }
- return
- }
-
- session.cancel()
- fileSearchSession = nil
- fileSearchCurrentPath = nil
- if clearResults {
- resetFileSearchState()
- }
- else {
- fileSearchStatus = .cancelled(processed: fileSearchScannedFolders)
- }
- }
-
- @MainActor fileprivate func searchSession(_ session: FileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) {
- guard fileSearchSession === session else {
- return
- }
-
- var appended: [FileInfo] = []
- for match in matches {
- let key = searchPathKey(for: match.path)
- if fileSearchResultKeys.insert(key).inserted {
- appended.append(match)
- }
- }
-
- if !appended.isEmpty {
- fileSearchResults.append(contentsOf: appended)
- }
-
- fileSearchScannedFolders = processed
- fileSearchStatus = .searching(processed: processed, pending: pending)
- }
-
- @MainActor fileprivate func searchSession(_ session: FileSearchSession, didFocusOn path: [String]) {
- guard fileSearchSession === session else {
- return
- }
-
- fileSearchCurrentPath = path
- }
-
- @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) {
- guard fileSearchSession === session else {
- return
- }
-
- fileSearchScannedFolders = processed
- fileSearchSession = nil
- fileSearchCurrentPath = nil
- if completed {
- fileSearchStatus = .completed(processed: processed)
- } else {
- fileSearchStatus = .cancelled(processed: processed)
- }
- }
-
- private func resetFileSearchState() {
- fileSearchResults = []
- fileSearchResultKeys.removeAll(keepingCapacity: true)
- fileSearchStatus = .idle
- fileSearchQuery = ""
- fileSearchScannedFolders = 0
- fileSearchCurrentPath = nil
- }
-
- private func searchPathKey(for path: [String]) -> String {
- path.joined(separator: "\u{001F}")
- }
-
- private func shouldBypassFileCache(for path: [String]) -> Bool {
- guard let folderName = path.last else {
- return false
- }
-
- let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines)
-
- if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil {
- return true
- }
-
- if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil {
- return true
- }
-
- if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil {
- return true
- }
-
- return false
- }
-
- private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? {
- guard ttl > 0 else {
- return nil
- }
-
- if shouldBypassFileCache(for: path) {
- return nil
- }
-
- let key = searchPathKey(for: path)
- guard let entry = fileListCache[key] else {
- return nil
- }
-
- let age = Date().timeIntervalSince(entry.timestamp)
- let isFresh = age <= ttl
- if !allowStale && !isFresh {
- return nil
- }
-
- return (entry.files, isFresh)
- }
-
- private func storeFileListInCache(_ files: [FileInfo], for path: [String]) {
- guard fileSearchConfig.cacheTTL > 0 else {
- return
- }
-
- if shouldBypassFileCache(for: path) {
- return
- }
-
- let key = searchPathKey(for: path)
- fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date())
- pruneFileListCacheIfNeeded()
- }
-
- private func pruneFileListCacheIfNeeded() {
- let limit = fileSearchConfig.maxCachedFolders
- guard limit > 0, fileListCache.count > limit else {
- return
- }
-
- let excess = fileListCache.count - limit
- guard excess > 0 else { return }
-
- let sortedKeys = fileListCache.sorted { lhs, rhs in
- lhs.value.timestamp < rhs.value.timestamp
- }
-
- for index in 0..<excess {
- let key = sortedKeys[index].key
- fileListCache.removeValue(forKey: key)
- }
- }
-
- private func invalidateFileListCache(for path: [String], includingAncestors: Bool = false) {
- guard !fileListCache.isEmpty else {
- return
- }
-
- var currentPath = path
- while true {
- let key = searchPathKey(for: currentPath)
- fileListCache.removeValue(forKey: key)
-
- if !includingAncestors || currentPath.isEmpty {
- break
- }
-
- currentPath.removeLast()
- if currentPath.isEmpty {
- let rootKey = searchPathKey(for: currentPath)
- fileListCache.removeValue(forKey: rootKey)
- break
- }
- }
- }
-
- fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? {
- cachedFileList(for: path, ttl: ttl, allowStale: true)
- }
-
- @MainActor func clearFileListCache() {
- guard !fileListCache.isEmpty else {
- return
- }
-
- fileListCache.removeAll(keepingCapacity: false)
- }
-
- @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(reference: 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(reference: referenceNumber, title: fileName, size: UInt(transferSize))
- transfer.downloadCallback = callback
- transfer.progressCallback = progressCallback
- self.transfers.append(transfer)
-
- fileClient.start(to: fileURL)
- }
- }
- }
-
- @MainActor func downloadFolder(_ folderName: 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.sendDownloadFolder(name: folderName, path: fullPath) { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadItemCount, downloadWaitingCount in
- print("GOT DOWNLOAD FOLDER REPLY:")
- print("\tSUCCESS?", success)
- print("\tTRANSFER SIZE: \(downloadTransferSize.debugDescription)")
- print("\tITEM COUNT: \(downloadItemCount.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 itemCount = downloadItemCount {
-
- print("Creating HotlineFolderDownloadClient with address: \(address), port: \(port)")
-
- let folderClient = HotlineFolderDownloadClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), itemCount: itemCount)
- folderClient.delegate = self
- self.downloads.append(folderClient)
-
- let transfer = TransferInfo(reference: referenceNumber, title: folderName, size: UInt(transferSize))
- transfer.isFolder = true
- transfer.downloadCallback = callback
- self.transfers.append(transfer)
-
- // Create destination folder in Downloads
- let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
- let folderURL = downloadsURL.appendingPathComponent(folderName)
-
- print("Starting folder download to: \(folderURL.path)")
- folderClient.start(to: folderURL)
- print("Folder download started")
- }
- }
- }
-
- @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)")
-
- invalidateFileListCache(for: path, includingAncestors: true)
-
- 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(reference: 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) { [weak self] success in
- if success {
- self?.invalidateFileListCache(for: fullPath, includingAncestors: true)
- }
- 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)")
- let previousStatus = self.status
- let previousTitle = self.serverTitle
-
- if status == .disconnected {
- if previousStatus == .loggedIn {
- let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date())
- self.recordChatMessage(message, persist: true, display: false)
- }
-
- self.serverVersion = 123
- self.serverName = nil
- self.access = nil
-
- self.fileSearchSession?.cancel()
- self.fileSearchSession = nil
- self.resetFileSearchState()
-
- 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()
-
- self.chatSessionKey = nil
- self.restoredChatSessionKey = nil
- self.lastPersistedMessageType = nil
- }
- 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) {
- let message = ChatMessage(text: text, type: .agreement, date: Date())
- self.recordChatMessage(message, persist: false)
- }
-
- 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)")
- let chatMessage = ChatMessage(text: message, type: .server, date: Date())
- self.recordChatMessage(chatMessage)
- }
-
- 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)
- }
- let chatMessage = ChatMessage(text: message, type: .message, date: Date())
- self.recordChatMessage(chatMessage)
- 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 {
- let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date())
- self.recordChatMessage(chatMessage)
- }
-
- 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)
- }
- }
-
- func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) {
- // Optional: Update transfer title to show current file being downloaded
- if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
- let transfer = self.transfers[i]
- // You could update the title here to show progress: "FolderName (file 3 of 10)"
- // transfer.title = "\(originalFolderName) (\(itemNumber) of \(totalItems))"
- }
- }
-
- func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, 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)
- }
- }
-
- // MARK: - Utilities
-
- private func sessionKey(for server: Server) -> ChatStore.SessionKey {
- ChatStore.SessionKey(address: server.address.lowercased(), port: server.port)
- }
-
- private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) {
- let shouldPersist = persist && message.type != .agreement
- if shouldPersist,
- message.type == .signOut,
- lastPersistedMessageType == .signOut {
- return
- }
-
- if display {
- self.chat.append(message)
- }
-
- guard shouldPersist, let key = chatSessionKey else { return }
- self.lastPersistedMessageType = message.type
- let entry = ChatStore.Entry(
- id: message.id,
- body: message.text,
- username: message.username,
- type: message.type.storageKey,
- date: message.date
- )
- let serverName = self.serverName ?? self.server?.name
-
- Task {
- await ChatStore.shared.append(entry: entry, for: key, serverName: serverName)
- }
- }
-
- private func restoreChatHistory(for key: ChatStore.SessionKey) {
- if restoredChatSessionKey == key {
- return
- }
-
- Task { [weak self] in
- guard let self else { return }
- let result = await ChatStore.shared.loadHistory(for: key)
- await MainActor.run {
- guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return }
- let currentMessages = self.chat
- let historyMessages = result.entries.compactMap { entry -> ChatMessage? in
- guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil }
- let renderedText: String
- if chatType == .message, let username = entry.username, !username.isEmpty {
- renderedText = "\(username): \(entry.body)"
- }
- else {
- renderedText = entry.body
- }
- var message = ChatMessage(text: renderedText, type: chatType, date: entry.date)
- message.metadata = entry.metadata
- return message
- }
- self.chat = historyMessages + currentMessages
- self.lastPersistedMessageType = historyMessages.last?.type
- self.unreadPublicChat = false
- self.restoredChatSessionKey = key
- }
- }
- }
-
- private func handleChatHistoryCleared() {
- self.chat = []
- self.unreadPublicChat = false
- self.restoredChatSessionKey = nil
- self.lastPersistedMessageType = nil
- }
-
- @MainActor func searchChat(query: String) -> [ChatMessage] {
- guard !query.isEmpty else {
- return []
- }
-
- // Create a map of all messages by ID to deduplicate (current chat includes restored history)
- var messageMap: [UUID: ChatMessage] = [:]
-
- // Add current in-memory messages (includes both restored history and new messages)
- for message in self.chat {
- messageMap[message.id] = message
- }
-
- // Filter messages based on query
- let filteredMessages = messageMap.values.filter { message in
- // Never include agreement messages
- if message.type == .agreement {
- return false
- }
-
- // Always include disconnect messages to show session boundaries
- let isDisconnect = message.type == .signOut
-
- // Search in text and username
- let matchesText = message.text.localizedCaseInsensitiveContains(query)
- let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true
- let matchesQuery = matchesText || matchesUsername
-
- return isDisconnect || matchesQuery
- }
-
- // Sort by date to maintain chronological order
- let sortedMessages = filteredMessages.sorted { $0.date < $1.date }
-
- // Remove consecutive disconnect messages to avoid visual clutter
- var deduplicated: [ChatMessage] = []
- var lastWasDisconnect = false
-
- for message in sortedMessages {
- let isDisconnect = message.type == .signOut
-
- if isDisconnect && lastWasDisconnect {
- // Skip consecutive disconnect messages
- continue
- }
-
- deduplicated.append(message)
- lastWasDisconnect = isDisconnect
- }
-
- // Remove leading disconnect message
- if deduplicated.first?.type == .signOut {
- deduplicated.removeFirst()
- }
-
- // Remove trailing disconnect message
- if deduplicated.last?.type == .signOut {
- deduplicated.removeLast()
- }
-
- return deduplicated
- }
-
- 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 {
- let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date())
- self.recordChatMessage(chatMessage)
- }
- }
- }
-
- 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
- }
- }
-}
-
-@MainActor
-final class FileSearchSession {
- private struct FolderTask {
- let path: [String]
- let depth: Int
- let isHot: Bool
- }
-
- private weak var hotline: Hotline?
- private let queryTokens: [String]
- private let config: FileSearchConfig
-
- private var queue: [FolderTask] = []
- private var visited: Set<String> = []
- private var loopHistogram: [String: Int] = [:]
-
- private var processedCount: Int = 0
- private var currentDelay: TimeInterval
- private var isCancelled = false
-
- init(hotline: Hotline, query: String, config: FileSearchConfig) {
- self.hotline = hotline
- self.queryTokens = query.lowercased().split(separator: " ").map(String.init)
- self.config = config
- self.currentDelay = config.initialDelay
- }
-
- func start() async {
- guard let hotline else {
- return
- }
-
- await Task.yield()
-
- if !hotline.filesLoaded {
- hotline.searchSession(self, didFocusOn: [])
- let rootFiles = await hotline.getFileList(path: [], suppressErrors: true, preferCache: true)
- processedCount = max(processedCount, 1)
- processListing(rootFiles, depth: 0, parentPath: [], parentIsHot: false)
- }
- else {
- hotline.searchSession(self, didFocusOn: [])
- processedCount = max(processedCount, 1)
- processListing(hotline.files, depth: 0, parentPath: [], parentIsHot: false)
- }
-
- while !queue.isEmpty && !isCancelled {
- await Task.yield()
-
- guard let task = dequeueNextTask() else {
- continue
- }
- if shouldSkip(path: task.path, depth: task.depth) {
- hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count)
- continue
- }
-
- hotline.searchSession(self, didFocusOn: task.path)
- visited.insert(pathKey(for: task.path))
-
- if let cached = hotline.cachedListingForSearch(path: task.path, ttl: config.cacheTTL) {
- if cached.isFresh {
- processedCount += 1
- processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
- continue
- } else {
- processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
- }
- }
-
- let children = await hotline.getFileList(path: task.path, suppressErrors: true)
- processedCount += 1
-
- if isCancelled {
- break
- }
-
- processListing(children, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
-
- await applyBackoff()
- }
-
- hotline.searchSessionDidFinish(self, processed: processedCount, pending: queue.count, completed: !isCancelled)
- }
-
- func cancel() {
- isCancelled = true
- }
-
- private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) {
- guard let hotline else {
- return
- }
-
- var matches: [FileInfo] = []
- var folderEntries: [(file: FileInfo, isHot: Bool)] = []
- var hasFileMatch = false
-
- for file in items {
- let matchesName = nameMatchesQuery(file.name)
-
- if matchesName {
- matches.append(file)
- if !file.isFolder {
- hasFileMatch = true
- }
- }
-
- if file.isFolder && !file.isAppBundle {
- folderEntries.append((file, matchesName))
- }
- }
-
- var remainingBurst = 0
- if config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) {
- remainingBurst = config.hotBurstLimit
- }
-
- if remainingBurst > 0 {
- var candidateIndices: [Int] = []
- for index in folderEntries.indices where !folderEntries[index].isHot {
- candidateIndices.append(index)
- }
-
- if !candidateIndices.isEmpty {
- candidateIndices.shuffle()
- for index in candidateIndices {
- folderEntries[index].isHot = true
- remainingBurst -= 1
- if remainingBurst == 0 {
- break
- }
- }
- }
- }
-
- for entry in folderEntries {
- enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot)
- }
-
- hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count)
- }
-
- private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) {
- guard !isCancelled else { return }
- guard depth <= config.maxDepth else { return }
-
- let path = folder.path
- let key = pathKey(for: path)
- guard !visited.contains(key) else { return }
-
- if exceedsLoopThreshold(for: path) {
- return
- }
-
- queue.append(FolderTask(path: path, depth: depth, isHot: markHot))
- }
-
- private func dequeueNextTask() -> FolderTask? {
- guard !queue.isEmpty else {
- return nil
- }
-
- if queue.count == 1 {
- return queue.removeFirst()
- }
-
- let currentDepth = queue[0].depth
- var lastSameDepthIndex = 0
- var hotIndices: [Int] = []
-
- for index in 0..<queue.count {
- let candidate = queue[index]
- if candidate.depth == currentDepth {
- lastSameDepthIndex = index
- if candidate.isHot {
- hotIndices.append(index)
- }
- } else {
- break
- }
- }
-
- let selectionPool: [Int]
- if !hotIndices.isEmpty {
- selectionPool = hotIndices
- } else {
- selectionPool = Array(0...lastSameDepthIndex)
- }
-
- let randomIndex = selectionPool.randomElement() ?? 0
- return queue.remove(at: randomIndex)
- }
-
- private func shouldSkip(path: [String], depth: Int) -> Bool {
- if isCancelled {
- return true
- }
-
- if depth > config.maxDepth {
- return true
- }
-
- let key = pathKey(for: path)
- if visited.contains(key) {
- return true
- }
-
- return false
- }
-
- private func nameMatchesQuery(_ name: String) -> Bool {
- guard !queryTokens.isEmpty else { return false }
- let lowercased = name.lowercased()
- return queryTokens.allSatisfy { lowercased.contains($0) }
- }
-
- private func exceedsLoopThreshold(for path: [String]) -> Bool {
- guard config.loopRepetitionLimit > 0 else { return false }
- guard let last = path.last else { return false }
- let parent = path.dropLast()
-
- guard let previousIndex = parent.lastIndex(of: last) else {
- return false
- }
-
- let suffix = Array(path[previousIndex...])
- let key = suffix.joined(separator: "\u{001F}")
- let count = (loopHistogram[key] ?? 0) + 1
- loopHistogram[key] = count
- return count > config.loopRepetitionLimit
- }
-
- private func pathKey(for path: [String]) -> String {
- path.joined(separator: "\u{001F}")
- }
-
- private func applyBackoff() async {
- guard !isCancelled else { return }
-
- if processedCount > config.initialBurstCount {
- currentDelay = min(config.maxDelay, max(config.initialDelay, currentDelay * config.backoffMultiplier))
- }
-
- guard currentDelay > 0 else {
- return
- }
-
- let nanoseconds = UInt64(currentDelay * 1_000_000_000)
- try? await Task.sleep(nanoseconds: nanoseconds)
- }
-}
diff --git a/Hotline/Models/HotlineState.swift b/Hotline/Models/HotlineState.swift
deleted file mode 100644
index d5ab438..0000000
--- a/Hotline/Models/HotlineState.swift
+++ /dev/null
@@ -1,2468 +0,0 @@
-import SwiftUI
-
-// MARK: - Connection Status
-
-enum HotlineConnectionStatus: Equatable {
- case disconnected
- case connecting
- case connected
- case loggedIn
- case failed(String)
-
- var isConnected: Bool {
- return self == .connected || self == .loggedIn
- }
-}
-
-struct FileSearchConfig: Equatable {
- /// Number of folders we process before we start applying delay backoff.
- var initialBurstCount: Int = 15
- /// Base delay applied between folder requests during the backoff phase.
- var initialDelay: TimeInterval = 0.02
- /// Multiplier used to increase the delay after each processed folder in backoff.
- var backoffMultiplier: Double = 1.1
- /// Maximum delay cap so searches don't stall out during long walks.
- var maxDelay: TimeInterval = 1.0
- /// Maximum recursion depth allowed during file search.
- var maxDepth: Int = 40
- /// Limit for repeated folder loops (guards against circular server listings).
- var loopRepetitionLimit: Int = 4
- /// Number of child folders that get prioritized after a matching parent is found.
- var hotBurstLimit: Int = 2
- /// Maximum age, in seconds, that a cached folder listing is treated as fresh.
- var cacheTTL: TimeInterval = 60 * 15
- /// Upper bound on the number of folder listings retained in the cache.
- var maxCachedFolders: Int = 1024 * 3
-}
-
-enum FileSearchStatus: Equatable {
- case idle
- case searching(processed: Int, pending: Int)
- case completed(processed: Int)
- case cancelled(processed: Int)
- case failed(String)
-
- var isActive: Bool {
- if case .searching = self {
- return true
- }
- return false
- }
-}
-
-// MARK: - HotlineState
-
-@Observable @MainActor
-class HotlineState: Equatable {
- let id: UUID = UUID()
-
- nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool {
- return lhs.id == rhs.id
- }
-
- // MARK: - Static Icon Data
-
- #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
-
- 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
- ]
-
- // MARK: - Observable State
-
- var status: HotlineConnectionStatus = .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
-
- // Users
- var users: [User] = []
-
- // Chat
- var chat: [ChatMessage] = []
- var chatInput: String = ""
- var unreadPublicChat: Bool = false
-
- // Instant Messages
- var instantMessages: [UInt16:[InstantMessage]] = [:]
- var unreadInstantMessages: [UInt16:UInt16] = [:]
-
- // Message Board
- var messageBoard: [String] = []
- var messageBoardLoaded: Bool = false
-
- // News
- var news: [NewsInfo] = []
- var newsLoaded: Bool = false
- private var newsLookup: [String:NewsInfo] = [:]
-
- // Files
- var files: [FileInfo] = []
- var filesLoaded: Bool = false
-
- // Accounts
- var accounts: [HotlineAccount] = []
- var accountsLoaded: Bool = false
-
- // Banner
- #if os(macOS)
- var bannerImage: Image? = nil
- var bannerColors: ColorArt? = nil
- #elseif os(iOS)
- var bannerImage: UIImage? = nil
- #endif
-
- // Transfers (now stored globally in AppState)
- /// Returns all transfers associated with this server
- var transfers: [TransferInfo] {
- AppState.shared.transfers.filter { $0.serverID == self.id }
- }
-
- // Legacy transfer tracking (for old delegate-based downloads)
-// @ObservationIgnored private var downloads: [HotlineTransferClient] = []
- @ObservationIgnored private var bannerDownloadTask: Task<Void, Never>? = nil
-
- // File Search
- var fileSearchResults: [FileInfo] = []
- var fileSearchStatus: FileSearchStatus = .idle
- var fileSearchQuery: String = ""
- var fileSearchConfig = FileSearchConfig()
- var fileSearchScannedFolders: Int = 0
- var fileSearchCurrentPath: [String]? = nil
- @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil
- @ObservationIgnored private var fileSearchResultKeys: Set<String> = []
-
- // File List Cache
- private struct FileListCacheEntry {
- let files: [FileInfo]
- let timestamp: Date
- }
- @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:]
-
- // Error Display
- var errorDisplayed: Bool = false
- var errorMessage: String? = nil
-
- // MARK: - Private State
-
- @ObservationIgnored private var client: HotlineClientNew?
- @ObservationIgnored private var eventTask: Task<Void, Never>?
- @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey?
- @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey?
- @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol?
- @ObservationIgnored private var lastPersistedMessageType: ChatMessageType?
-
- // MARK: - Initialization
-
- init() {
- self.chatHistoryObserver = NotificationCenter.default.addObserver(
- forName: ChatStore.historyClearedNotification,
- object: nil,
- queue: .main
- ) { @MainActor [weak self] _ in
- self?.handleChatHistoryCleared()
- }
- }
-
- deinit {
- if let observer = self.chatHistoryObserver {
- NotificationCenter.default.removeObserver(observer)
- }
- }
-
- // MARK: - Connection
-
- @MainActor
- func login(server: Server, username: String, iconID: Int) async throws {
- print("HotlineState.login(): Starting login to \(server.address):\(server.port)")
- self.server = server
- self.username = username
- self.iconID = iconID
- self.status = .connecting
- print("HotlineState.login(): Status set to connecting")
-
- // Set up chat session
- let key = self.sessionKey(for: server)
- self.chatSessionKey = key
- self.restoredChatSessionKey = nil
- self.lastPersistedMessageType = nil
- self.chat = []
- self.restoreChatHistory(for: key)
- print("HotlineState.login(): Chat session set up")
-
- do {
- // Connect and login
- let loginInfo = HotlineLoginInfo(
- login: server.login,
- password: server.password,
- username: username,
- iconID: UInt16(iconID)
- )
-
- print("HotlineState.login(): Calling HotlineClientNew.connect()...")
- let client = try await HotlineClientNew.connect(
- host: server.address,
- port: UInt16(server.port),
- login: loginInfo
- )
- print("HotlineState.login(): HotlineClientNew.connect() returned")
-
- self.client = client
- print("HotlineState.login(): Client stored")
-
- // Get server info
- print("HotlineState.login(): Getting server info...")
- if let serverInfo = await client.server {
- self.serverVersion = serverInfo.version
- if !serverInfo.name.isEmpty {
- self.serverName = serverInfo.name
- }
- print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)")
- }
-
- self.status = .connected
- print("HotlineState.login(): Status set to connected")
-
- // Request initial data before starting event loop
- print("HotlineState.login(): Requesting user list...")
- try await self.getUserList()
-
- self.status = .loggedIn
- print("HotlineState.login(): Status set to loggedIn")
-
- if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound {
- SoundEffectPlayer.shared.playSoundEffect(.loggedIn)
- }
-
- print("HotlineState.login(): Connected to \(self.serverTitle)")
- print("HotlineState.login(): Scheduling post-login tasks...")
-
- // Defer event loop and post-login work to avoid layout recursion
- // This allows login() to return and SwiftUI to complete its layout pass
- // before we start receiving events that trigger state changes
- Task { @MainActor in
- print("HotlineState: Post-login: Starting event loop...")
- self.startEventLoop()
-
- print("HotlineState: Post-login: Sending preferences...")
- try? await self.sendUserPreferences()
-
- print("HotlineState: Post-login: Downloading banner...")
- self.downloadBanner()
- }
-
- } catch {
- print("HotlineState.login(): Login failed with error: \(error)")
- if let client = self.client {
- await client.disconnect()
- self.client = nil
- }
- self.status = .failed(error.localizedDescription)
- self.errorDisplayed = true
- self.errorMessage = error.localizedDescription
- throw error
- }
- }
-
- /// Disconnect from the server (user-initiated)
- @MainActor
- func disconnect() async {
- print("HotlineState.disconnect(): Called")
- guard let client = self.client else {
- print("HotlineState.disconnect(): No client, returning")
- return
- }
-
- // Stop event loop
- print("HotlineState.disconnect(): Cancelling event task...")
- self.eventTask?.cancel()
- self.eventTask = nil
- print("HotlineState.disconnect(): Event task cancelled")
-
- // Explicitly close the connection
- print("HotlineState.disconnect(): Calling client.disconnect()...")
- await client.disconnect()
- print("HotlineState.disconnect(): client.disconnect() returned")
-
- // Clean up state
- print("HotlineState.disconnect(): Calling handleConnectionClosed()...")
- self.handleConnectionClosed()
- print("HotlineState.disconnect(): disconnect() complete")
- }
-
- /// Handle connection closure (server-initiated or after user disconnect)
- @MainActor
- private func handleConnectionClosed() {
- print("HotlineState: handleConnectionClosed() entered")
- guard self.client != nil else {
- print("HotlineState: handleConnectionClosed() - client already nil, returning")
- return
- }
-
- print("HotlineState: Handling connection closure - recording chat...")
-
- // Record disconnect in chat history
- if self.status == .loggedIn {
- let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date())
- self.recordChatMessage(message, persist: true, display: false)
- }
-
- print("HotlineState: Cancelling banner and downloads...")
-
- self.bannerDownloadTask?.cancel()
- self.bannerDownloadTask = nil
-
- // Cancel all downloads (both old delegate-based and new async downloads)
-// self.downloads = []
-
- // Cancel all transfers for this server
-// self.cancelAllDownloads()
-
- // Cancel file search
- self.fileSearchSession?.cancel()
- self.fileSearchSession = nil
-
- // Clear client reference
- self.client = nil
-
- print("HotlineState: Resetting state properties...")
-
- // Reset state immediately (constraint loop was caused by something else)
- self.status = .disconnected
- self.serverVersion = 123
- self.serverName = nil
- self.access = nil
- self.agreed = false
- self.users = []
- self.chat = []
- self.instantMessages = [:]
- self.unreadInstantMessages = [:]
- self.unreadPublicChat = false
- self.messageBoard = []
- self.messageBoardLoaded = false
- self.news = []
- self.newsLoaded = false
- self.newsLookup = [:]
- self.files = []
- self.filesLoaded = false
- self.accounts = []
- self.accountsLoaded = false
- self.bannerImage = nil
- self.bannerColors = nil
-
- print("HotlineState: Resetting file search...")
- self.resetFileSearchState()
-
- self.chatSessionKey = nil
- self.restoredChatSessionKey = nil
- self.lastPersistedMessageType = nil
-
- print("HotlineState: Disconnected")
- }
-
- @MainActor
- func downloadBanner(force: Bool = false) {
- guard self.serverVersion >= 150 else {
- return
- }
-
- if force {
- self.bannerDownloadTask?.cancel()
- self.bannerDownloadTask = nil
- self.bannerImage = nil
- self.bannerColors = nil
- } else if self.bannerDownloadTask != nil || self.bannerImage != nil {
- return
- }
-
- let task = Task { @MainActor [weak self] in
- defer {
- self?.bannerDownloadTask = nil
- }
-
- guard let self else { return }
- guard let client = self.client,
- let server = self.server,
- let result = try? await client.downloadBanner(),
- let address = server.address as String?,
- let port = server.port as Int?
- else {
- return
- }
-
- do {
- print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)")
-
- let previewClient = HotlineFilePreviewClientNew(
- fileName: "banner",
- address: address,
- port: UInt16(port),
- reference: result.referenceNumber,
- size: UInt32(result.transferSize)
- )
-
- let fileURL = try await previewClient.preview()
- defer {
- previewClient.cleanup()
- }
-
- guard self.client != nil else { return }
-
- let data = try Data(contentsOf: fileURL)
- print("HotlineState: Banner download complete, data size: \(data.count) bytes")
-
-#if os(macOS)
- guard let image = NSImage(data: data) else {
- print("HotlineState: Failed to create NSImage from banner data")
- return
- }
- let blah = Image(nsImage: image)
-#elseif os(iOS)
- guard let image = UIImage(data: data) else {
- print("HotlineState: Failed to create UIImage from banner data")
- return
- }
- self.bannerImage = Image(uiImage: image)
-#endif
- self.bannerImage = blah
- self.bannerColors = ColorArt.analyze(image: image)
-
- } catch {
- print("HotlineState: Banner download failed: \(error)")
- }
- }
-
- self.bannerDownloadTask = task
- }
-
- // MARK: - Event Loop
-
- private func startEventLoop() {
- print("HotlineState.startEventLoop(): Called")
- guard let client = self.client else {
- print("HotlineState.startEventLoop(): No client, returning")
- return
- }
-
- print("HotlineState.startEventLoop(): Creating event loop task")
- self.eventTask = Task { @MainActor [weak self, client] in
- guard let self else {
- print("HotlineState.startEventLoop(): Self is nil in task, exiting")
- return
- }
-
- print("HotlineState.startEventLoop(): Event loop started, awaiting events...")
- for await event in client.events {
- print("HotlineState.startEventLoop(): Received event: \(event)")
- self.handleEvent(event)
- }
-
- // Event stream ended - server disconnected us
- print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...")
- self.handleConnectionClosed()
- print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete")
- }
- print("HotlineState.startEventLoop(): Event loop task created")
- }
-
- @MainActor
- private func handleEvent(_ event: HotlineEvent) {
- switch event {
- case .chatMessage(let text):
- self.handleChatMessage(text)
-
- case .userChanged(let user):
- self.handleUserChanged(user)
-
- case .userDisconnected(let userID):
- self.handleUserDisconnected(userID)
-
- case .serverMessage(let message):
- self.handleServerMessage(message)
-
- case .privateMessage(let userID, let message):
- self.handlePrivateMessage(userID: userID, message: message)
-
- case .newsPost(let message):
- self.handleNewsPost(message)
-
- case .agreementRequired(let text):
- let message = ChatMessage(text: text, type: .agreement, date: Date())
- self.recordChatMessage(message, persist: false)
-
- case .userAccess(let options):
- self.access = options
- print("HotlineState: Got access options")
- HotlineUserAccessOptions.printAccessOptions(options)
- }
- }
-
- // MARK: - Chat
-
- @MainActor
- func sendChat(_ text: String, announce: Bool = false) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- try await client.sendChat(text, announce: announce)
- }
-
- @MainActor
- func sendInstantMessage(_ text: String, userID: UInt16) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- 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)
- }
-
- try await client.sendInstantMessage(text, to: userID)
-
- if Prefs.shared.playPrivateMessageSound {
- SoundEffectPlayer.shared.playSoundEffect(.chatMessage)
- }
- }
-
- @MainActor
- func sendAgree() async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- try await client.sendAgree()
- self.agreed = true
- }
-
- @MainActor
- /// Send current user preferences from Prefs to the server
- func sendUserPreferences() async throws {
- var options: HotlineUserOptions = []
-
- if Prefs.shared.refusePrivateMessages {
- options.update(with: .refusePrivateMessages)
- }
-
- if Prefs.shared.refusePrivateChat {
- options.update(with: .refusePrivateChat)
- }
-
- if Prefs.shared.enableAutomaticMessage {
- options.update(with: .automaticResponse)
- }
-
- print("HotlineState.sendUserPreferences(): Updating user info with server")
-
- try await self.sendUserInfo(
- username: Prefs.shared.username,
- iconID: Prefs.shared.userIconID,
- options: options,
- autoresponse: Prefs.shared.automaticMessage
- )
- }
-
- func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- self.username = username
- self.iconID = iconID
-
- try await client.setClientUserInfo(
- username: username,
- iconID: UInt16(iconID),
- options: options,
- autoresponse: autoresponse
- )
- }
-
- 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 searchChat(query: String) -> [ChatMessage] {
- guard !query.isEmpty else {
- return []
- }
-
- // Create a map of all messages by ID to deduplicate
- var messageMap: [UUID: ChatMessage] = [:]
-
- // Add current in-memory messages
- for message in self.chat {
- messageMap[message.id] = message
- }
-
- // Filter messages based on query
- let filteredMessages = messageMap.values.filter { message in
- // Never include agreement messages
- if message.type == .agreement {
- return false
- }
-
- // Always include disconnect messages to show session boundaries
- let isDisconnect = message.type == .signOut
-
- // Search in text and username
- let matchesText = message.text.localizedCaseInsensitiveContains(query)
- let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true
- let matchesQuery = matchesText || matchesUsername
-
- return isDisconnect || matchesQuery
- }
-
- // Sort by date to maintain chronological order
- let sortedMessages = filteredMessages.sorted { $0.date < $1.date }
-
- // Remove consecutive disconnect messages to avoid visual clutter
- var deduplicated: [ChatMessage] = []
- var lastWasDisconnect = false
-
- for message in sortedMessages {
- let isDisconnect = message.type == .signOut
-
- if isDisconnect && lastWasDisconnect {
- continue
- }
-
- deduplicated.append(message)
- lastWasDisconnect = isDisconnect
- }
-
- // Remove leading disconnect message
- if deduplicated.first?.type == .signOut {
- deduplicated.removeFirst()
- }
-
- // Remove trailing disconnect message
- if deduplicated.last?.type == .signOut {
- deduplicated.removeLast()
- }
-
- return deduplicated
- }
-
- // MARK: - Users
-
- @MainActor
- func getUserList() async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- let hotlineUsers = try await client.getUserList()
- self.users = hotlineUsers.map { User(hotlineUser: $0) }
- }
-
- // MARK: - Files (Basic)
-
- @MainActor
- @discardableResult
- func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- // Check cache first if preferred
- if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) {
- return cached.items
- }
-
- let hotlineFiles = try await client.getFileList(path: path)
- let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) }
-
- // Update UI state
- if path.isEmpty {
- self.filesLoaded = true
- self.files = newFiles
- } else {
- // Update parent's children
- let parentFile = self.findFile(in: self.files, at: path)
- parentFile?.children = newFiles
- }
-
- // Cache the result
- self.storeFileListInCache(newFiles, for: path)
-
- return newFiles
- }
-
- @MainActor
- func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
- }
-
- return try await client.getFileInfo(name: fileName, path: fullPath)
- }
-
- @MainActor
- func deleteFile(_ fileName: String, path: [String]) async throws -> Bool {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
- }
-
- let success = try await client.deleteFile(name: fileName, path: fullPath)
-
- if success {
- self.invalidateFileListCache(for: fullPath, includingAncestors: true)
- }
-
- return success
- }
-
-// @MainActor
-// func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo, Double) -> Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) {
-// guard let client = self.client else { return }
-//
-// var fullPath: [String] = []
-// if path.count > 1 {
-// fullPath = Array(path[0..<path.count-1])
-// }
-//
-// Task { @MainActor in
-// guard let result = try? await client.downloadFile(name: fileName, path: fullPath),
-// let server = self.server,
-// let address = server.address as String?,
-// let port = server.port as Int?
-// else {
-// return
-// }
-//
-// let fileClient = HotlineFileDownloadClient(
-// address: address,
-// port: UInt16(port),
-// reference: result.referenceNumber,
-// size: UInt32(result.transferSize)
-// )
-// fileClient.delegate = self
-// self.downloads.append(fileClient)
-//
-// let transfer = TransferInfo(
-// id: result.referenceNumber,
-// title: fileName,
-// size: UInt(result.transferSize),
-// serverID: self.id,
-// serverName: self.serverName ?? self.serverTitle
-// )
-// transfer.downloadCallback = callback
-// transfer.progressCallback = progressCallback
-// AppState.shared.transfers.append(transfer)
-//
-// if let destination {
-// fileClient.start(to: destination)
-// } else {
-// fileClient.start()
-// }
-// }
-// }
-
- /// Modern async/await file download using HotlineFileDownloadClientNew
- ///
- /// This is a cleaner implementation that uses the new async/await download client.
- /// Unlike the old version, it doesn't use delegates and returns the file URL directly.
- ///
- /// - Parameters:
- /// - fileName: Name of the file to download
- /// - path: Path components to the file (includes the filename as last component)
- /// - destination: Optional destination URL. If nil, downloads to Downloads folder.
- /// - progressCallback: Optional callback for progress updates (receives TransferInfo and progress 0.0-1.0)
- /// - callback: Optional completion callback (receives TransferInfo and final file URL)
- @MainActor
- func downloadFileNew(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) {
- guard let client = self.client else { return }
-
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
- }
-
- Task { @MainActor [weak self] in
- guard let self else { return }
-
- // Request download from server
- guard let result = try? await client.downloadFile(name: fileName, path: fullPath),
- let server = self.server,
- let address = server.address as String?,
- let port = server.port as Int?
- else {
- return
- }
-
- let referenceNumber = result.referenceNumber
-
- // Create transfer info for tracking (stored globally in AppState)
- let transfer = TransferInfo(
- reference: referenceNumber,
- title: fileName,
- size: UInt(result.transferSize),
- serverID: self.id,
- serverName: self.serverName ?? self.serverTitle
- )
- transfer.downloadCallback = callback
- transfer.progressCallback = progressCallback
- AppState.shared.addTransfer(transfer)
-
- // Create download client
- let downloadClient = HotlineFileDownloadClientNew(
- address: address,
- port: UInt16(port),
- reference: referenceNumber,
- size: UInt32(result.transferSize)
- )
-
- // Create and store the download task
- let downloadTask = Task { @MainActor [weak self] in
- guard self != nil else { return }
-
- do {
- let fileURL: URL
-
- // Download file with progress tracking
- let location: HotlineDownloadLocation = if let destination {
- .url(destination)
- } else {
- .downloads(fileName)
- }
-
- fileURL = try await downloadClient.download(to: location) { progress in
- switch progress {
- case .preparing:
- break
- case .unconnected, .connected, .connecting:
- transfer.progressCallback?(transfer)
- case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate):
- transfer.timeRemaining = estimate
- transfer.speed = speed
- transfer.progress = progress
- transfer.progressCallback?(transfer)
- case .error(_):
- transfer.failed = true
- case .completed(url: let url):
- transfer.completed = true
- transfer.fileURL = url
- }
- }
-
- // Mark as completed
- transfer.progress = 1.0
-
- // Call completion callback
- transfer.downloadCallback?(transfer)
-
- fileURL.notifyDownloadFinished()
-
- print("HotlineState: Download complete - \(fileURL.path)")
-
- } catch is CancellationError {
- // Download was cancelled
- print("HotlineState: Download cancelled")
- } catch {
- // Mark as failed
- transfer.failed = true
- print("HotlineState: Download failed - \(error)")
- }
-
- AppState.shared.unregisterTransferTask(for: transfer.id)
- }
-
- // Store the task in AppState so it can be cancelled later
- AppState.shared.registerTransferTask(downloadTask, transferID: transfer.id)
- }
- }
-
- /// Cancel all downloads for this server only
-// @MainActor
-// func cancelAllDownloads() {
-// let myTransfers = self.transfers
-// for transfer in myTransfers {
-// AppState.shared.cancelDownload(transfer.id)
-// }
-// }
-
-// @MainActor
-// func downloadFolder(_ folderName: String, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) {
-// guard let client = self.client else { return }
-//
-// var fullPath: [String] = []
-// if path.count > 1 {
-// fullPath = Array(path[0..<path.count-1])
-// }
-//
-// Task { @MainActor in
-// guard let result = try? await client.downloadFolder(name: folderName, path: fullPath),
-// let server = self.server,
-// let address = server.address as String?,
-// let port = server.port as Int?
-// else {
-// return
-// }
-//
-// let folderClient = HotlineFolderDownloadClient(
-// address: address,
-// port: UInt16(port),
-// reference: result.referenceNumber,
-// size: UInt32(result.transferSize),
-// itemCount: result.itemCount
-// )
-// folderClient.delegate = self
-// self.downloads.append(folderClient)
-//
-// let transfer = TransferInfo(
-// id: result.referenceNumber,
-// title: folderName,
-// size: UInt(result.transferSize),
-// serverID: self.id,
-// serverName: self.serverName ?? self.serverTitle
-// )
-// transfer.isFolder = true
-// transfer.downloadCallback = callback
-// AppState.shared.transfers.append(transfer)
-//
-// folderClient.start()
-// }
-// }
-
- /// Modern async/await folder download using HotlineFolderDownloadClientNew
- ///
- /// This is a cleaner implementation that uses the new async/await download client.
- /// Unlike the old version, it doesn't use delegates and returns the folder URL directly.
- ///
- /// - Parameters:
- /// - folderName: Name of the folder to download
- /// - path: Path components to the folder (includes the foldername as last component)
- /// - destination: Optional destination URL. If nil, downloads to Downloads folder.
- /// - progressCallback: Optional callback for progress updates (receives TransferInfo)
- /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info)
- /// - callback: Optional completion callback (receives TransferInfo and final folder URL)
- @MainActor
- func downloadFolderNew(
- _ folderName: String,
- path: [String],
- to destination: URL? = nil,
- progress progressCallback: ((TransferInfo) -> Void)? = nil,
- itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil,
- complete callback: ((TransferInfo) -> Void)? = nil
- ) {
- guard let client = self.client else { return }
-
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
- }
-
- Task { @MainActor [weak self] in
- guard let self else { return }
-
- // Request folder download from server
- guard let result = try? await client.downloadFolder(name: folderName, path: fullPath),
- let server = self.server,
- let address = server.address as String?,
- let port = server.port as Int?
- else {
- return
- }
-
- let referenceNumber = result.referenceNumber
-
- // Create transfer info for tracking (stored globally in AppState)
- let transfer = TransferInfo(
- reference: referenceNumber,
- title: folderName,
- size: UInt(result.transferSize),
- serverID: self.id,
- serverName: self.serverName ?? self.serverTitle
- )
- transfer.isFolder = true
- transfer.downloadCallback = callback
- transfer.progressCallback = progressCallback
- AppState.shared.addTransfer(transfer)
-
- // Create download client
- let downloadClient = HotlineFolderDownloadClientNew(
- address: address,
- port: UInt16(port),
- reference: referenceNumber,
- size: UInt32(result.transferSize),
- itemCount: result.itemCount
- )
-
- // Create and store the download task
- let downloadTask = Task { @MainActor [weak self] in
- guard self != nil else { return }
-
- do {
- let folderURL: URL
-
- // Download folder with progress tracking
- let location: HotlineDownloadLocation = if let destination {
- .url(destination)
- } else {
- .downloads(folderName)
- }
-
- folderURL = try await downloadClient.download(to: location, progress: { progress in
- switch progress {
- case .preparing:
- break
- case .unconnected, .connected, .connecting:
- transfer.progressCallback?(transfer)
- case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate):
- transfer.timeRemaining = estimate
- transfer.speed = speed
- transfer.progress = progress
- transfer.progressCallback?(transfer)
- case .error(_):
- transfer.failed = true
- case .completed(url: let url):
- transfer.completed = true
- transfer.fileURL = url
- }
- }, itemProgress: { itemInfo in
- // Update transfer title with current file being downloaded
- transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))"
- itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems)
- })
-
- // Mark as completed
- transfer.progress = 1.0
- transfer.title = folderName // Reset title to folder name
-
- // Call completion callback
- transfer.downloadCallback?(transfer)
-
- folderURL.notifyDownloadFinished()
-
- print("HotlineState: Folder download complete - \(folderURL.path)")
-
- } catch is CancellationError {
- // Download was cancelled
- print("HotlineState: Folder download cancelled")
- } catch {
- // Mark as failed
- transfer.failed = true
- print("HotlineState: Folder download failed - \(error)")
- }
-
- AppState.shared.unregisterTransferTask(for: transfer.id)
- }
-
- // Store transfer
- AppState.shared.registerTransferTask(downloadTask, transferID: transfer.id)
- }
- }
-
- /// Modern async/await folder upload using HotlineFolderUploadClientNew
- ///
- /// - Parameters:
- /// - folderURL: URL to the folder on disk to upload
- /// - path: Destination path on the server where the folder should be uploaded
- /// - progressCallback: Optional callback for progress updates (receives TransferInfo)
- /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info)
- /// - callback: Optional completion callback (receives TransferInfo when upload is complete)
- @MainActor
- func uploadFolder(
- url folderURL: URL,
- path: [String],
- progress progressCallback: ((TransferInfo) -> Void)? = nil,
- itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil,
- complete callback: ((TransferInfo) -> Void)? = nil
- ) {
- guard let client = self.client else { return }
-
- let folderName = folderURL.lastPathComponent
-
- guard folderURL.isFileURL, !folderName.isEmpty else {
- print("HotlineState: Not a valid folder URL")
- return
- }
-
- let folderPath = folderURL.path(percentEncoded: false)
-
- var isDirectory: ObjCBool = false
- guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory),
- isDirectory.boolValue == true else {
- print("HotlineState: URL is not a folder")
- return
- }
-
- // Get the total size of the folder (all files)
- guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else {
- print("HotlineState: Could not determine folder size")
- return
- }
-
- print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total")
-
- Task { @MainActor [weak self] in
- guard let self else { return }
-
- // Request folder upload from server.
- // The enumerator already omits the root folder, so report the full item count the server should expect.
- let reportedItemCount = fileCount
- print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)")
- guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)),
- let server = self.server,
- let address = server.address as String?,
- let port = server.port as Int?
- else {
- print("HotlineState: Failed to get upload reference from server")
- return
- }
-
- // Invalidate cache for the upload destination
- self.invalidateFileListCache(for: path, includingAncestors: true)
-
- print("HotlineState: Got folder upload reference: \(referenceNumber)")
-
- // Create upload client
- guard let uploadClient = HotlineFolderUploadClientNew(
- folderURL: folderURL,
- address: address,
- port: UInt16(port),
- reference: referenceNumber
- ) else {
- print("HotlineState: Failed to create folder upload client")
- return
- }
-
- // Create transfer info for tracking (stored globally in AppState)
- let transfer = TransferInfo(
- reference: referenceNumber,
- title: folderName,
- size: UInt(folderSize),
- serverID: self.id,
- serverName: self.serverName ?? self.serverTitle
- )
- transfer.isFolder = true
- transfer.uploadCallback = callback
- transfer.progressCallback = progressCallback
- AppState.shared.addTransfer(transfer)
-
- // Create and store the upload task
- let uploadTask = Task { @MainActor [weak self] in
- guard self != nil else { return }
-
- do {
- // Upload folder with progress tracking
- try await uploadClient.upload(progress: { progress in
- switch progress {
- case .preparing:
- break
- case .unconnected, .connected, .connecting:
- break
- case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate):
- transfer.timeRemaining = estimate
- transfer.speed = speed
- transfer.progress = progress
- transfer.progressCallback?(transfer)
- case .error(_):
- transfer.failed = true
- case .completed(url: _):
- transfer.completed = true
- }
- }, itemProgress: { itemInfo in
- // Update transfer title with current file being uploaded
- transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))"
- itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems)
- })
-
- // Mark as completed
- transfer.progress = 1.0
- transfer.title = folderName // Reset title to folder name
-
- // Call completion callback
- transfer.uploadCallback?(transfer)
-
- print("HotlineState: Folder upload complete - \(folderName)")
-
- } catch is CancellationError {
- // Upload was cancelled
- print("HotlineState: Folder upload cancelled")
- } catch {
- // Mark as failed
- transfer.failed = true
- print("HotlineState: Folder upload failed - \(error)")
- }
-
- AppState.shared.unregisterTransferTask(for: transfer.id)
- }
-
- // Store the task in AppState so it can be cancelled later
- AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id)
- }
- }
-
- func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) {
- guard let client = self.client else { return }
-
- let fileName = fileURL.lastPathComponent
-
- guard fileURL.isFileURL, !fileName.isEmpty else {
- print("HotlineState: Not a valid 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("HotlineState: File is a directory")
- return
- }
-
- // Get the flattened file size (includes all forks and headers)
- guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else {
- print("HotlineState: Could not determine file size")
- return
- }
-
- Task { @MainActor [weak self] in
- guard let self else { return }
-
- // Request upload from server
- guard let referenceNumber = try? await client.uploadFile(name: fileName, path: path),
- let server = self.server,
- let address = server.address as String?,
- let port = server.port as Int?
- else {
- print("HotlineState: Failed to get upload reference from server")
- return
- }
-
- // Invalidate cache for the upload destination
- self.invalidateFileListCache(for: path, includingAncestors: true)
-
- print("HotlineState: Got upload reference: \(referenceNumber)")
-
- // Create upload client
- guard let uploadClient = HotlineFileUploadClientNew(
- fileURL: fileURL,
- address: address,
- port: UInt16(port),
- reference: referenceNumber
- ) else {
- print("HotlineState: Failed to create upload client")
- return
- }
-
- // Create transfer info for tracking (stored globally in AppState)
- let transfer = TransferInfo(
- reference: referenceNumber,
- title: fileName,
- size: UInt(payloadSize),
- serverID: self.id,
- serverName: self.serverName ?? self.serverTitle
- )
- transfer.uploadCallback = callback
- AppState.shared.addTransfer(transfer)
-
- // Create and store the upload task
- let uploadTask = Task { @MainActor [weak self] in
- guard self != nil else { return }
-
- do {
- // Upload file with progress tracking
- try await uploadClient.upload { progress in
- switch progress {
- case .preparing:
- break
- case .unconnected, .connected, .connecting:
- break
- case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate):
- transfer.timeRemaining = estimate
- transfer.speed = speed
- transfer.progress = progress
- case .error(_):
- transfer.failed = true
- case .completed(url: _):
- transfer.completed = true
- }
- }
-
- // Mark as completed
- transfer.progress = 1.0
-
- // Call completion callback
- transfer.uploadCallback?(transfer)
-
- print("HotlineState: Upload complete - \(fileName)")
-
- } catch is CancellationError {
- // Upload was cancelled
- print("HotlineState: Upload cancelled")
- } catch {
- // Mark as failed
- transfer.failed = true
- print("HotlineState: Upload failed - \(error)")
- }
-
- AppState.shared.unregisterTransferTask(for: transfer.id)
- }
-
- // Store the transfer
- AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id)
- }
- }
-
- func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) {
- // TODO: Implement setFileInfo in HotlineClientNew
- // This method updates file metadata (name and/or comment)
- print("setFileInfo not yet implemented in HotlineState/HotlineClientNew")
- }
-
- @MainActor
- func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) {
- guard let client = self.client else {
- callback?(nil)
- return
- }
-
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
- }
-
- Task { @MainActor in
- guard let result = try? await client.downloadFile(name: fileName, path: fullPath, preview: true),
- let server = self.server,
- let address = server.address as String?,
- let port = server.port as Int?
- else {
- callback?(nil)
- return
- }
-
- let info = PreviewFileInfo(
- id: result.referenceNumber,
- address: address,
- port: port,
- size: result.transferSize,
- name: fileName
- )
-
- callback?(info)
- }
- }
-
- // MARK: - User Administration
-
- @MainActor
- func getAccounts() async throws -> [HotlineAccount] {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- self.accounts = try await client.getAccounts()
- self.accountsLoaded = true
- return self.accounts
- }
-
- @MainActor
- func createUser(name: String, login: String, password: String?, access: UInt64) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- try await client.createUser(name: name, login: login, password: password, access: access)
-
- // Refresh accounts list
- self.accounts = try await client.getAccounts()
- }
-
- @MainActor
- func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access)
-
- // Refresh accounts list
- self.accounts = try await client.getAccounts()
- }
-
- @MainActor
- func deleteUser(login: String) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- try await client.deleteUser(login: login)
-
- // Refresh accounts list
- self.accounts = try await client.getAccounts()
- }
-
- // MARK: - Message Board
-
- @MainActor
- func getMessageBoard() async throws -> [String] {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- self.messageBoard = try await client.getMessageBoard()
- self.messageBoardLoaded = true
- return self.messageBoard
- }
-
- @MainActor
- func postToMessageBoard(text: String) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- try await client.postMessageBoard(text)
- }
-
- // MARK: - News
-
- @MainActor
- func getNewsList(at path: [String] = []) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- 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("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))")
-
- let categories = try await client.getNewsCategories(path: path)
-
- // 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("HotlineState: 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("HotlineState: 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
- }
- } else {
- print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))")
-
- let articles = try await client.getNewsArticles(path: path)
-
- print("HotlineState: 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("HotlineState: 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("HotlineState: 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
- }
- }
- }
-
- @MainActor
- func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor)
- }
-
- @MainActor
- func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws {
- guard let client = self.client else {
- throw HotlineClientError.notConnected
- }
-
- try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID)
- print("HotlineState: News article posted")
- }
-
- // MARK: - File Search
-
- @MainActor
- func startFileSearch(query: String) {
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- self.cancelFileSearch()
- return
- }
-
- self.fileSearchSession?.cancel()
- self.resetFileSearchState()
- self.fileSearchQuery = trimmed
- self.fileSearchStatus = .searching(processed: 0, pending: 0)
- self.fileSearchScannedFolders = 0
- self.fileSearchCurrentPath = []
-
- let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig)
- self.fileSearchSession = session
-
- Task { await session.start() }
- }
-
- @MainActor
- func cancelFileSearch(clearResults: Bool = true) {
- guard let session = self.fileSearchSession else {
- if clearResults {
- self.resetFileSearchState()
- } else if !self.fileSearchResults.isEmpty {
- self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders)
- self.fileSearchCurrentPath = nil
- }
- return
- }
-
- session.cancel()
- self.fileSearchSession = nil
- self.fileSearchCurrentPath = nil
-
- if clearResults {
- self.resetFileSearchState()
- } else {
- self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders)
- }
- }
-
- @MainActor
- func clearFileListCache() {
- guard !self.fileListCache.isEmpty else {
- return
- }
-
- self.fileListCache.removeAll(keepingCapacity: false)
- }
-
- @MainActor
- fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) {
- guard self.fileSearchSession === session else {
- return
- }
-
- var appended: [FileInfo] = []
- for match in matches {
- let key = self.searchPathKey(for: match.path)
- if self.fileSearchResultKeys.insert(key).inserted {
- appended.append(match)
- }
- }
-
- if !appended.isEmpty {
- self.fileSearchResults.append(contentsOf: appended)
- }
-
- self.fileSearchScannedFolders = processed
- self.fileSearchStatus = .searching(processed: processed, pending: pending)
- }
-
- @MainActor
- fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) {
- guard self.fileSearchSession === session else {
- return
- }
-
- self.fileSearchCurrentPath = path
- }
-
- @MainActor
- fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) {
- guard self.fileSearchSession === session else {
- return
- }
-
- self.fileSearchScannedFolders = processed
- self.fileSearchSession = nil
- self.fileSearchCurrentPath = nil
-
- if completed {
- self.fileSearchStatus = .completed(processed: processed)
- } else {
- self.fileSearchStatus = .cancelled(processed: processed)
- }
- }
-
- fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? {
- self.cachedFileList(for: path, ttl: ttl, allowStale: true)
- }
-
- // MARK: - Event Handlers
-
- private func handleChatMessage(_ text: String) {
- if Prefs.shared.playSounds && Prefs.shared.playChatSound {
- SoundEffectPlayer.shared.playSoundEffect(.chatMessage)
- }
-
- let chatMessage = ChatMessage(text: text, type: .message, date: Date())
- self.recordChatMessage(chatMessage)
- self.unreadPublicChat = true
- }
-
- private func handleUserChanged(_ user: HotlineUser) {
- self.addOrUpdateHotlineUser(user)
- }
-
- private func handleUserDisconnected(_ userID: UInt16) {
- if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) {
- let user = self.users.remove(at: existingUserIndex)
-
- if Prefs.shared.showJoinLeaveMessages {
- let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date())
- self.recordChatMessage(chatMessage)
- }
-
- if Prefs.shared.playSounds && Prefs.shared.playLeaveSound {
- SoundEffectPlayer.shared.playSoundEffect(.userLogout)
- }
- }
- }
-
- private func handleServerMessage(_ message: String) {
- if Prefs.shared.playSounds && Prefs.shared.playChatSound {
- SoundEffectPlayer.shared.playSoundEffect(.serverMessage)
- }
-
- print("HotlineState: received server message:\n\(message)")
- let chatMessage = ChatMessage(text: message, type: .server, date: Date())
- self.recordChatMessage(chatMessage)
- }
-
- private func handlePrivateMessage(userID: UInt16, message: String) {
- if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) {
- let user = self.users[existingUserIndex]
- print("HotlineState: received private message from \(user.name): \(message)")
-
- if 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
- }
- }
-
- private func handleNewsPost(_ 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)
- }
-
- // MARK: - User Management
-
- private func addOrUpdateHotlineUser(_ user: HotlineUser) {
- print("HotlineState: users: \n\(self.users)")
-
- if let i = self.users.firstIndex(where: { $0.id == user.id }) {
- print("HotlineState: 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("HotlineState: added user: \(user.name)")
- self.users.append(User(hotlineUser: user))
-
- if Prefs.shared.showJoinLeaveMessages {
- let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date())
- self.recordChatMessage(chatMessage)
- }
- }
- }
-
- // MARK: - Chat Persistence
-
- private func sessionKey(for server: Server) -> ChatStore.SessionKey {
- ChatStore.SessionKey(address: server.address.lowercased(), port: server.port)
- }
-
- private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) {
- let shouldPersist = persist && message.type != .agreement
- if shouldPersist,
- message.type == .signOut,
- self.lastPersistedMessageType == .signOut {
- return
- }
-
- if display {
- self.chat.append(message)
- }
-
- guard shouldPersist, let key = self.chatSessionKey else { return }
- self.lastPersistedMessageType = message.type
-
- let entry = ChatStore.Entry(
- id: message.id,
- body: message.text,
- username: message.username,
- type: message.type.storageKey,
- date: message.date
- )
- let serverName = self.serverName ?? self.server?.name
-
- Task {
- await ChatStore.shared.append(entry: entry, for: key, serverName: serverName)
- }
- }
-
- private func restoreChatHistory(for key: ChatStore.SessionKey) {
- if self.restoredChatSessionKey == key {
- return
- }
-
- Task { [weak self] in
- guard let self else { return }
- let result = await ChatStore.shared.loadHistory(for: key)
-
- await MainActor.run {
- guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return }
-
- let currentMessages = self.chat
- let historyMessages = result.entries.compactMap { entry -> ChatMessage? in
- guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil }
-
- let renderedText: String
- if chatType == .message, let username = entry.username, !username.isEmpty {
- renderedText = "\(username): \(entry.body)"
- } else {
- renderedText = entry.body
- }
-
- var message = ChatMessage(text: renderedText, type: chatType, date: entry.date)
- message.metadata = entry.metadata
- return message
- }
-
- self.chat = historyMessages + currentMessages
- self.lastPersistedMessageType = historyMessages.last?.type
- self.unreadPublicChat = false
- self.restoredChatSessionKey = key
- }
- }
- }
-
- private func handleChatHistoryCleared() {
- self.chat = []
- self.unreadPublicChat = false
- self.restoredChatSessionKey = nil
- self.lastPersistedMessageType = nil
- }
-
- // MARK: - Utilities
-
- func updateServerTitle() {
- self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server"
- }
-
- // News helpers
- 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] {
- if parentArticle.children.firstIndex(of: article) == nil {
- article.expanded = true
- parentArticle.children.append(article)
- }
- } else {
- organized.append(article)
- }
- }
-
- return organized
- }
-
- 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
- }
-
- // File helpers
- 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
- }
-
- // File search helpers
- private func searchPathKey(for path: [String]) -> String {
- path.joined(separator: "\u{001F}")
- }
-
- private func resetFileSearchState() {
- self.fileSearchResults = []
- self.fileSearchResultKeys.removeAll(keepingCapacity: true)
- self.fileSearchStatus = .idle
- self.fileSearchQuery = ""
- self.fileSearchScannedFolders = 0
- self.fileSearchCurrentPath = nil
- }
-
- // File cache helpers
- private func shouldBypassFileCache(for path: [String]) -> Bool {
- guard let folderName = path.last else {
- return false
- }
-
- let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines)
-
- if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil {
- return true
- }
-
- if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil {
- return true
- }
-
- if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil {
- return true
- }
-
- return false
- }
-
- private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? {
- guard ttl > 0 else {
- return nil
- }
-
- if self.shouldBypassFileCache(for: path) {
- return nil
- }
-
- let key = self.searchPathKey(for: path)
- guard let entry = self.fileListCache[key] else {
- return nil
- }
-
- let age = Date().timeIntervalSince(entry.timestamp)
- let isFresh = age <= ttl
- if !allowStale && !isFresh {
- return nil
- }
-
- return (entry.files, isFresh)
- }
-
- private func storeFileListInCache(_ files: [FileInfo], for path: [String]) {
- guard self.fileSearchConfig.cacheTTL > 0 else {
- return
- }
-
- if self.shouldBypassFileCache(for: path) {
- return
- }
-
- let key = self.searchPathKey(for: path)
- self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date())
- self.pruneFileListCacheIfNeeded()
- }
-
- private func pruneFileListCacheIfNeeded() {
- let limit = self.fileSearchConfig.maxCachedFolders
- guard limit > 0, self.fileListCache.count > limit else {
- return
- }
-
- let excess = self.fileListCache.count - limit
- guard excess > 0 else { return }
-
- let sortedKeys = self.fileListCache.sorted { lhs, rhs in
- lhs.value.timestamp < rhs.value.timestamp
- }
-
- for index in 0..<excess {
- let key = sortedKeys[index].key
- self.fileListCache.removeValue(forKey: key)
- }
- }
-
- private func invalidateFileListCache(for path: [String], includingAncestors: Bool = false) {
- guard !self.fileListCache.isEmpty else {
- return
- }
-
- var currentPath = path
- while true {
- let key = self.searchPathKey(for: currentPath)
- self.fileListCache.removeValue(forKey: key)
-
- if !includingAncestors || currentPath.isEmpty {
- break
- }
-
- currentPath.removeLast()
- if currentPath.isEmpty {
- let rootKey = self.searchPathKey(for: currentPath)
- self.fileListCache.removeValue(forKey: rootKey)
- break
- }
- }
- }
-
- // MARK: - Transfer Delegates
-
-// nonisolated func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) {
-// Task { @MainActor in
-// // Update transfer info with progress
-// guard let transferIndex = self.transfers.firstIndex(where: { $0.id == reference }) else {
-// return
-// }
-//
-// let transfer = self.transfers[transferIndex]
-// transfer.timeRemaining = timeRemaining
-//
-// switch status {
-// case .progress(let p):
-// transfer.progress = p
-// transfer.progressCallback?(transfer)
-// case .completed:
-// transfer.completed = true
-// transfer.progress = 1.0
-// case .failed(_):
-// transfer.failed = true
-// default:
-// break
-// }
-// }
-// }
-
-// nonisolated func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) {
-// // Info fork received (file metadata)
-// }
-
-// nonisolated func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at url: URL) {
-// Task { @MainActor in
-// // Find and remove the download client
-// if let downloadIndex = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
-// self.downloads.remove(at: downloadIndex)
-// }
-//
-// // Find and complete the transfer
-// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
-// return
-// }
-//
-// let transfer = AppState.shared.transfers[transferIndex]
-// transfer.fileURL = url
-// transfer.downloadCallback?(transfer)
-// AppState.shared.transfers.remove(at: transferIndex)
-// }
-// }
-
-// nonisolated func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) {
-// Task { @MainActor in
-// // Update transfer info with current file being downloaded
-// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
-// return
-// }
-//
-// let transfer = AppState.shared.transfers[transferIndex]
-// transfer.title = "\(fileName) (\(itemNumber)/\(totalItems))"
-// }
-// }
-
-// nonisolated func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at url: URL) {
-// Task { @MainActor in
-// // Find and remove the download client
-// if let downloadIndex = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
-// self.downloads.remove(at: downloadIndex)
-// }
-//
-// // Find and complete the transfer
-// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
-// return
-// }
-//
-// let transfer = AppState.shared.transfers[transferIndex]
-// transfer.fileURL = url
-// transfer.downloadCallback?(transfer)
-// AppState.shared.transfers.remove(at: transferIndex)
-// }
-// }
-}
-
-// MARK: - File Search Session
-
-@MainActor
-final class HotlineStateFileSearchSession {
- private struct FolderTask {
- let path: [String]
- let depth: Int
- let isHot: Bool
- }
-
- private weak var hotlineState: HotlineState?
- private let queryTokens: [String]
- private let config: FileSearchConfig
-
- private var queue: [FolderTask] = []
- private var visited: Set<String> = []
- private var loopHistogram: [String: Int] = [:]
-
- private var processedCount: Int = 0
- private var currentDelay: TimeInterval
- private var isCancelled = false
-
- init(hotlineState: HotlineState, query: String, config: FileSearchConfig) {
- self.hotlineState = hotlineState
- self.queryTokens = query.lowercased().split(separator: " ").map(String.init)
- self.config = config
- self.currentDelay = config.initialDelay
- }
-
- func start() async {
- guard let hotlineState else {
- return
- }
-
- await Task.yield()
-
- if !hotlineState.filesLoaded {
- hotlineState.searchSession(self, didFocusOn: [])
- let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true)
- self.processedCount = max(self.processedCount, 1)
- self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false)
- } else {
- hotlineState.searchSession(self, didFocusOn: [])
- self.processedCount = max(self.processedCount, 1)
- self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false)
- }
-
- while !self.queue.isEmpty && !self.isCancelled {
- await Task.yield()
-
- guard let task = self.dequeueNextTask() else {
- continue
- }
-
- if self.shouldSkip(path: task.path, depth: task.depth) {
- hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count)
- continue
- }
-
- hotlineState.searchSession(self, didFocusOn: task.path)
- self.visited.insert(self.pathKey(for: task.path))
-
- if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) {
- if cached.isFresh {
- self.processedCount += 1
- self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
- continue
- } else {
- self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
- }
- }
-
- let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true)
- self.processedCount += 1
-
- if self.isCancelled {
- break
- }
-
- self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
-
- await self.applyBackoff()
- }
-
- hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled)
- }
-
- func cancel() {
- self.isCancelled = true
- }
-
- private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) {
- guard let hotlineState else {
- return
- }
-
- var matches: [FileInfo] = []
- var folderEntries: [(file: FileInfo, isHot: Bool)] = []
- var hasFileMatch = false
-
- for file in items {
- let matchesName = self.nameMatchesQuery(file.name)
-
- if matchesName {
- matches.append(file)
- if !file.isFolder {
- hasFileMatch = true
- }
- }
-
- if file.isFolder && !file.isAppBundle {
- folderEntries.append((file, matchesName))
- }
- }
-
- var remainingBurst = 0
- if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) {
- remainingBurst = self.config.hotBurstLimit
- }
-
- if remainingBurst > 0 {
- var candidateIndices: [Int] = []
- for index in folderEntries.indices where !folderEntries[index].isHot {
- candidateIndices.append(index)
- }
-
- if !candidateIndices.isEmpty {
- candidateIndices.shuffle()
- for index in candidateIndices {
- folderEntries[index].isHot = true
- remainingBurst -= 1
- if remainingBurst == 0 {
- break
- }
- }
- }
- }
-
- for entry in folderEntries {
- self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot)
- }
-
- hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count)
- }
-
- private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) {
- guard !self.isCancelled else { return }
- guard depth <= self.config.maxDepth else { return }
-
- let path = folder.path
- let key = self.pathKey(for: path)
- guard !self.visited.contains(key) else { return }
-
- if self.exceedsLoopThreshold(for: path) {
- return
- }
-
- self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot))
- }
-
- private func dequeueNextTask() -> FolderTask? {
- guard !self.queue.isEmpty else {
- return nil
- }
-
- if self.queue.count == 1 {
- return self.queue.removeFirst()
- }
-
- let currentDepth = self.queue[0].depth
- var lastSameDepthIndex = 0
- var hotIndices: [Int] = []
-
- for index in 0..<self.queue.count {
- let candidate = self.queue[index]
- if candidate.depth == currentDepth {
- lastSameDepthIndex = index
- if candidate.isHot {
- hotIndices.append(index)
- }
- } else {
- break
- }
- }
-
- let selectionPool: [Int]
- if !hotIndices.isEmpty {
- selectionPool = hotIndices
- } else {
- selectionPool = Array(0...lastSameDepthIndex)
- }
-
- let randomIndex = selectionPool.randomElement() ?? 0
- return self.queue.remove(at: randomIndex)
- }
-
- private func shouldSkip(path: [String], depth: Int) -> Bool {
- if self.isCancelled {
- return true
- }
-
- if depth > self.config.maxDepth {
- return true
- }
-
- let key = self.pathKey(for: path)
- if self.visited.contains(key) {
- return true
- }
-
- return false
- }
-
- private func nameMatchesQuery(_ name: String) -> Bool {
- guard !self.queryTokens.isEmpty else { return false }
- let lowercased = name.lowercased()
- return self.queryTokens.allSatisfy { lowercased.contains($0) }
- }
-
- private func exceedsLoopThreshold(for path: [String]) -> Bool {
- guard self.config.loopRepetitionLimit > 0 else { return false }
- guard let last = path.last else { return false }
- let parent = path.dropLast()
-
- guard let previousIndex = parent.lastIndex(of: last) else {
- return false
- }
-
- let suffix = Array(path[previousIndex...])
- let key = suffix.joined(separator: "\u{001F}")
- let count = (self.loopHistogram[key] ?? 0) + 1
- self.loopHistogram[key] = count
- return count > self.config.loopRepetitionLimit
- }
-
- private func pathKey(for path: [String]) -> String {
- path.joined(separator: "\u{001F}")
- }
-
- private func applyBackoff() async {
- guard !self.isCancelled else { return }
-
- if self.processedCount > self.config.initialBurstCount {
- self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier))
- }
-
- guard self.currentDelay > 0 else {
- return
- }
-
- let nanoseconds = UInt64(self.currentDelay * 1_000_000_000)
- try? await Task.sleep(nanoseconds: nanoseconds)
- }
-}