diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-11-07 10:39:48 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-11-07 10:39:48 -0800 |
| commit | 39f51fd902bb34272c78ffdfb872c22095382f70 (patch) | |
| tree | fee51effb6431aeb5464931052ecdd0df32c6792 /Hotline/Models | |
| parent | 87f08cf60a5d7c1cf618463916cbac4dab88e0f8 (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.swift | 1876 | ||||
| -rw-r--r-- | Hotline/Models/HotlineState.swift | 2468 |
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) - } -} |