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 | |
| parent | 87f08cf60a5d7c1cf618463916cbac4dab88e0f8 (diff) | |
Further cleanup of previous refactor. Removing old view model and NetSocket. Added some empty states for newsgroups and message board.
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 8 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 878 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineTransferClient.swift | 1834 | ||||
| -rw-r--r-- | Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift | 18 | ||||
| -rw-r--r-- | Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift | 38 | ||||
| -rw-r--r-- | Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift | 28 | ||||
| -rw-r--r-- | Hotline/Library/NetSocket.swift | 274 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 1876 | ||||
| -rw-r--r-- | Hotline/State/HotlineState.swift (renamed from Hotline/Models/HotlineState.swift) | 0 | ||||
| -rw-r--r-- | Hotline/macOS/Board/MessageBoardView.swift | 121 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilesView.swift | 272 | ||||
| -rw-r--r-- | Hotline/macOS/News/NewsView.swift | 42 |
12 files changed, 232 insertions, 5157 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a234fbc..cb621a0 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -137,7 +137,6 @@ DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = "<group>"; }; DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = "<group>"; }; DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = "<group>"; }; - DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = "<group>"; }; DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = "<group>"; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; @@ -161,7 +160,6 @@ DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularExpressions.swift; sourceTree = "<group>"; }; DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTransferClient.swift; sourceTree = "<group>"; }; DA57536B2B36BA1D00FAC277 /* TextDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDocument.swift; sourceTree = "<group>"; }; - DA6300962B24036B0034CBFD /* HotlineClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClient.swift; sourceTree = "<group>"; }; DA6549992BEC280E00EDB697 /* ServerMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessageView.swift; sourceTree = "<group>"; }; DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAgreementView.swift; sourceTree = "<group>"; }; DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWindowBridge.swift; sourceTree = "<group>"; }; @@ -209,7 +207,6 @@ DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = "<group>"; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = "<group>"; }; DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; }; - DADDB28E2B238D850024040D /* Hotline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hotline.swift; sourceTree = "<group>"; }; DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlinePanelView.swift; sourceTree = "<group>"; }; DAE734F82B2E4185000C56F6 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = "<group>"; }; DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerView.swift; sourceTree = "<group>"; }; @@ -255,6 +252,7 @@ DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, DA3429B42EBA8A450010784E /* FilePreviewState.swift */, + DA5268B02EB2708E00DCB941 /* HotlineState.swift */, ); path = State; sourceTree = "<group>"; @@ -409,7 +407,6 @@ DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, - DA4930BC2B4F8DD700822D0B /* NetSocket.swift */, DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, @@ -436,7 +433,6 @@ DABFCC262B1530AE009F40D2 /* Hotline */ = { isa = PBXGroup; children = ( - DA6300962B24036B0034CBFD /* HotlineClient.swift */, DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, @@ -476,8 +472,6 @@ DADDB2892B22B2C60024040D /* Models */ = { isa = PBXGroup; children = ( - DADDB28E2B238D850024040D /* Hotline.swift */, - DA5268B02EB2708E00DCB941 /* HotlineState.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift deleted file mode 100644 index 0e54872..0000000 --- a/Hotline/Hotline/HotlineClient.swift +++ /dev/null @@ -1,878 +0,0 @@ -import Foundation -import Network -import RegexBuilder - -enum HotlineClientStatus: Int { - case disconnected - case connecting - case connected - case loggingIn - case loggedIn -} - -enum HotlineTransactionError: Error { - case networkFailure - case timeout - case error(UInt32, String?) - case invalidMessage(UInt32, String?) -} - -struct HotlineTransactionInfo { - let type: HotlineTransactionType - let callback: ((HotlineTransaction) -> Void)? - let reply: ((HotlineTransaction) -> Void)? -} - -private struct HotlineLogin { - let login: String? - let password: String? - let username: String - let iconID: UInt16 - let callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)? -} - -protocol HotlineClientDelegate: AnyObject { - func hotlineGetUserInfo() -> (String, UInt16) - func hotlineStatusChanged(status: HotlineClientStatus) - func hotlineReceivedAgreement(text: String) - func hotlineReceivedErrorMessage(code: UInt32, message: String?) - func hotlineReceivedChatMessage(message: String) - func hotlineReceivedUserList(users: [HotlineUser]) - func hotlineReceivedServerMessage(message: String) - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) - func hotlineUserChanged(user: HotlineUser) - func hotlineUserDisconnected(userID: UInt16) - func hotlineReceivedNewsPost(message: String) -} - -extension HotlineClientDelegate { - func hotlineStatusChanged(status: HotlineClientStatus) {} - func hotlineReceivedAgreement(text: String) {} - func hotlineReceivedErrorMessage(code: UInt32, message: String?) {} - func hotlineReceivedChatMessage(message: String) {} - func hotlineReceivedUserList(users: [HotlineUser]) {} - func hotlineReceivedServerMessage(message: String) {} - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) {} - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) {} - func hotlineUserChanged(user: HotlineUser) {} - func hotlineUserDisconnected(userID: UInt16) {} - func hotlineReceivedNewsPost(message: String) {} -} - -enum HotlineClientStage { - case handshake - case packetHeader - case packetBody -} - -class HotlineClient: NetSocketDelegate { - static let handshakePacket = Data([ - 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID - 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID - 0x00, 0x01, // Version - 0x00, 0x02, // Sub-version - ]) - - static let HandshakePacket: [UInt8] = [ - 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID - 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID - 0x00, 0x01, // Version - 0x00, 0x02, // Sub-version - ] - - weak var delegate: HotlineClientDelegate? - - var connectionStatus: HotlineClientStatus = .disconnected - var connectCallback: ((Bool) -> Void)? - - private var serverAddress: String? = nil - private var serverPort: UInt16? = nil - - private struct TransactionContext { - let type: HotlineTransactionType - let callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? - let suppressErrors: Bool - } - - private var transactionLog: [UInt32: TransactionContext] = [:] - - private var socket: NetSocket? - private var stage: HotlineClientStage = .handshake - private var packet: HotlineTransaction? = nil - private var serverVersion: UInt16? = nil - private var loginDetails: HotlineLogin? = nil - private var keepAliveTimer: Timer? = nil - - init() {} - - // MARK: - NetSocket Delegate - - @MainActor func netsocketConnected(socket: NetSocket) { - self.updateConnectionStatus(.loggingIn) - self.stage = .handshake - } - - @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) { - self.reset() - self.updateConnectionStatus(.disconnected) - self.stage = .handshake - } - - @MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) { - switch self.stage { - case .handshake: - self.receiveHandshake() - case .packetHeader: - self.receivePacket() - case .packetBody: - self.receivePacket() - } - } - - // MARK: - Connect - - @MainActor func login(address: String, port: Int, login: String?, password: String?, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { - if self.socket != nil { - self.socket?.delegate = nil - self.socket?.close() - self.socket = nil - } - self.packet = nil - - self.loginDetails = HotlineLogin(login: login, password: password, username: username, iconID: iconID, callback: callback) - - self.socket = NetSocket() - self.socket?.delegate = self - - self.updateConnectionStatus(.connecting) - self.socket?.connect(host: address, port: port) - self.socket?.write(HotlineClient.HandshakePacket) - } - - @MainActor private func startKeepAliveTimer() { - self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 60 * 3, repeats: true) { [weak self] _ in - DispatchQueue.main.async { [weak self] in - self?.sendKeepAlive() - } - } - } - - @MainActor func receiveHandshake() { - guard let socket = self.socket, - self.stage == .handshake, - socket.available >= 8 else { - return - } - - var handshake: [UInt8] = socket.read(count: 8) - - // Verify handshake data - guard let protocolID = handshake.consumeUInt32(), - protocolID == "TRTP".fourCharCode() else { - // TODO: Close with appropriate error - socket.close() - return - } - - // Check for error code - guard let errorCode = handshake.consumeUInt32(), - errorCode == 0 else { - // TODO: Close with wrapped error - socket.close() - return - } - - self.stage = .packetHeader - - let session = self.loginDetails! - self.loginDetails = nil - self.sendLogin(login: session.login ?? "", password: session.password ?? "", username: session.username, iconID: session.iconID) { [weak self] err, serverName, serverVersion in - self?.serverVersion = serverVersion - self?.startKeepAliveTimer() - session.callback?(err, serverName, serverVersion) - } - - self.receivePacket() - } - - @MainActor private func reset() { - self.transactionLog = [:] - self.packet = nil - - self.keepAliveTimer?.invalidate() - self.keepAliveTimer = nil - - self.socket?.close() - self.socket?.delegate = nil - self.socket = nil - } - - @MainActor func disconnect() { - let wasConnected = self.connectionStatus != .disconnected - self.reset() - if wasConnected { - self.updateConnectionStatus(.disconnected) - } - } - - // MARK: - Packets - - @MainActor private func sendPacket(_ t: HotlineTransaction, suppressErrors: Bool = false, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { - guard let socket = self.socket else { - return - } - - print("HotlineClient => \(t.id) \(t.type)") - - if callback != nil || suppressErrors { - self.transactionLog[t.id] = TransactionContext(type: t.type, callback: callback, suppressErrors: suppressErrors) - } - - socket.write(t.encoded()) - } - - @MainActor private func receivePacket() { - guard let socket = self.socket else { - return - } - - var done: Bool = false - repeat { - switch self.stage { - case .packetHeader: - guard socket.has(HotlineTransaction.headerSize) else { - done = true - break - } - - let headerData: [UInt8] = socket.read(count: HotlineTransaction.headerSize) - guard let packet = HotlineTransaction(from: headerData) else { - done = true - break - } - - self.packet = packet - if packet.dataSize == 0 { - self.stage = .packetHeader - self.processPacket() - } - else { - self.stage = .packetBody - } - - case .packetBody: - guard let packet = self.packet, socket.has(Int(packet.dataSize)) else { - done = true - break - } - - let bodyData: [UInt8] = socket.read(count: Int(packet.dataSize)) - self.packet?.decodeFields(from: bodyData) - self.stage = .packetHeader - self.processPacket() - - default: - done = true - break - } - } while !done - } - - @MainActor private func processPacket() { - guard let packet = self.packet else { - return - } - - if packet.type == .reply || packet.isReply == 1 { - print("HotlineClient <= \(packet.type) to \(packet.id):") - } - else { - print("HotlineClient <= \(packet.type) \(packet.id)") - } - - if packet.isReply == 1 || packet.type == .reply { - self.processReplyPacket() - return - } - - // Mark packet is processed - self.packet = nil - - switch(packet.type) { - case .chatMessage: - if - let chatTextParam = packet.getField(type: .data), - let chatText = chatTextParam.getString() - { - print("HotlineClient: \(chatText)") - self.delegate?.hotlineReceivedChatMessage(message: chatText) - } - - case .notifyOfUserChange: - if let usernameField = packet.getField(type: .userName), - let username = usernameField.getString(), - let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16(), - let userIconIDField = packet.getField(type: .userIconID), - let userIconID = userIconIDField.getUInt16(), - let userFlagsField = packet.getField(type: .userFlags), - let userFlags = userFlagsField.getUInt16() { - print("HotlineClient: User changed \(userID) \(username) icon: \(userIconID)") - - let user = HotlineUser(id: userID, iconID: userIconID, status: userFlags, name: username) - self.delegate?.hotlineUserChanged(user: user) - } - - case .notifyOfUserDelete: - if let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16() { - self.delegate?.hotlineUserDisconnected(userID: userID) - } - - case .disconnectMessage: - // Server disconnected us. - print("HotlineClient ❌") - self.disconnect() - - case .serverMessage: - if let messageField = packet.getField(type: .data), - let message = messageField.getString() { - - if let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16() { - self.delegate?.hotlineReceivedPrivateMessage(userID: userID, message: message) - } - else { - self.delegate?.hotlineReceivedServerMessage(message: message) - } - } - - case .showAgreement: - if let _ = packet.getField(type: .noServerAgreement) { - // Server told us there is no agreement to show. - return - } - if let agreementParam = packet.getField(type: .data) { - if let agreementText = agreementParam.getString() { - self.delegate?.hotlineReceivedAgreement(text: agreementText) - } - } - - case .userAccess: - print("HotlineClient: user access info \(packet.getField(type: .userAccess).debugDescription)") - if let accessParam = packet.getField(type: .userAccess) { - if let accessValue = accessParam.getUInt64() { - let accessOptions = HotlineUserAccessOptions(rawValue: accessValue) - self.delegate?.hotlineReceivedUserAccess(options: accessOptions) - } - } - - case .newMessage: - if let messageField = packet.getField(type: .data), - let message = messageField.getString() { - self.delegate?.hotlineReceivedNewsPost(message: message) - } - - default: - print("HotlineClient: UNKNOWN transaction \(packet.type) with \(packet.fields.count) parameters") - print(packet.fields) - } - } - - @MainActor private func processReplyPacket() { - guard let packet = self.packet else { - return - } - - let context = self.transactionLog[packet.id] - self.transactionLog[packet.id] = nil - - if packet.errorCode != 0 { - let errorField: HotlineTransactionField? = packet.getField(type: .errorText) - print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")") - if context?.suppressErrors != true { - self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) - } - } - - if let context { - print("HotlineClient reply in response to \(context.type)") - } - - let replyCallback = context?.callback - - guard packet.errorCode == 0 else { - let errorField: HotlineTransactionField? = packet.getField(type: .errorText) - replyCallback?(packet, .error(packet.errorCode, errorField?.getString())) - return - } - - replyCallback?(packet, nil) - } - - // MARK: - Messages - - @MainActor func sendLogin(login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { - var t = HotlineTransaction(type: .login) - t.setFieldEncodedString(type: .userLogin, val: login) - t.setFieldEncodedString(type: .userPassword, val: password) - t.setFieldUInt16(type: .userIconID, val: iconID) - t.setFieldString(type: .userName, val: username) - t.setFieldUInt32(type: .versionNumber, val: 123) - - self.sendPacket(t) { [weak self] reply, err in - self?.updateConnectionStatus(.loggedIn) - - var serverVersion: UInt16? - var serverName: String? - - if - let serverVersionField = reply.getField(type: .versionNumber), - let serverVersionValue = serverVersionField.getUInt16() { - serverVersion = serverVersionValue - print("SERVER VERSION: \(serverVersionValue)") - } - - if - let serverNameField = reply.getField(type: .serverName), - let serverNameValue = serverNameField.getString() { - serverName = serverNameValue - print("SERVER NAME: \(serverNameValue)") - } - - callback?(err, serverName, serverVersion) - } - } - - @MainActor func sendSetClientUserInfo(username: String, iconID: UInt16, options: HotlineUserOptions = [], autoresponse: String? = nil) { - var t = HotlineTransaction(type: .setClientUserInfo) - t.setFieldString(type: .userName, val: username) - t.setFieldUInt16(type: .userIconID, val: iconID) - t.setFieldUInt16(type: .options, val: options.rawValue) - if let text = autoresponse { - t.setFieldString(type: .automaticResponse, val: text) - } - - self.sendPacket(t) - } - - @MainActor func sendAgree(username: String, iconID: UInt16, options: HotlineUserOptions) { - let t = HotlineTransaction(type: .agreed) -// t.setFieldString(type: .userName, val: username) -// t.setFieldUInt16(type: .userIconID, val: iconID) -// t.setFieldUInt8(type: .options, val: options.rawValue) - self.sendPacket(t) - } - - @MainActor func sendChat(message: String, encoding: String.Encoding = .utf8, announce: Bool = false) { - var t = HotlineTransaction(type: .sendChat) - t.setFieldString(type: .data, val: message, encoding: encoding) - t.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0) - self.sendPacket(t) - } - - @MainActor func sendInstantMessage(message: String, userID: UInt16, encoding: String.Encoding = .utf8) { - var t = HotlineTransaction(type: .sendInstantMessage) - t.setFieldUInt16(type: .userID, val: userID) - t.setFieldUInt32(type: .options, val: 1) - t.setFieldString(type: .data, val: message, encoding: encoding) - self.sendPacket(t) - } - - @MainActor func sendGetUserList() { - let t = HotlineTransaction(type: .getUserNameList) - self.sendPacket(t) { [weak self] reply, err in - var newUsers: [UInt16:HotlineUser] = [:] - var newUserList: [HotlineUser] = [] - for u in reply.getFieldList(type: .userNameWithInfo) { - let user = u.getUser() - newUsers[user.id] = user - newUserList.append(user) - } - self?.delegate?.hotlineReceivedUserList(users: newUserList) - } - } - - @MainActor func sendGetMessageBoard(callback: ((HotlineTransactionError?, [String]) -> Void)?) { - let t = HotlineTransaction(type: .getMessageBoard) - self.sendPacket(t) { reply, err in - guard err == nil, - let textField = reply.getField(type: .data), - let text = textField.getString() else { - callback?(err, []) - return - } - - var messages: [String] = [] - let matches = text.matches(of: RegularExpressions.messageBoardDivider) - var start = text.startIndex - - if matches.count > 0 { - for match in matches { - let range = match.range - let messageText = String(text[start..<range.lowerBound]) - messages.append(messageText.convertingLinksToMarkdown()) - start = range.upperBound - } - } - else { - messages.append(text) - } - - callback?(err, messages) - } - } - - @MainActor func sendPostMessageBoard(text: String) { - guard text.count > 0 else { - return - } - - var t = HotlineTransaction(type: .oldPostNews) - t.setFieldString(type: .data, val: text.convertingLineEndings(to: .cr), encoding: .macOSRoman) - self.sendPacket(t) - } - - @MainActor func sendGetNewsCategories(path: [String] = [], callback: (([HotlineNewsCategory]) -> Void)?) { - var t = HotlineTransaction(type: .getNewsCategoryNameList) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - - self.sendPacket(t) { reply, err in - var categories: [HotlineNewsCategory] = [] - for categoryListItem in reply.getFieldList(type: .newsCategoryListData15) { - var c = categoryListItem.getNewsCategory() - c.path = path + [c.name] - categories.append(c) - } - callback?(categories) - } - } - - @MainActor func sendGetNewsArticle(id articleID: UInt32, path: [String], flavor: String, callback: ((String?) -> Void)? = nil) { - var t = HotlineTransaction(type: .getNewsArticleData) - t.setFieldPath(type: .newsPath, val: path) - t.setFieldUInt32(type: .newsArticleID, val: articleID) - t.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - - self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleData), - let articleString = articleData.getString() else { - callback?(nil) - return - } - - callback?(articleString) - } - } - - @MainActor func postNewsArticle(title: String, text: String, path: [String] = [], parentID: UInt32 = 0, callback: ((Bool) -> Void)? = nil) { - guard !path.isEmpty else { - callback?(false) - return - } - - var t = HotlineTransaction(type: .postNewsArticle) - t.setFieldPath(type: .newsPath, val: path) - t.setFieldUInt32(type: .newsArticleID, val: parentID) - t.setFieldString(type: .newsArticleTitle, val: title) - t.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") - t.setFieldUInt32(type: .newsArticleFlags, val: 0) - t.setFieldString(type: .newsArticleData, val: text.convertingLineEndings(to: .cr)) - - print("HotlineClient postings \(title) under \(parentID)") - - self.sendPacket(t) { reply, err in - guard err == nil else { - callback?(false) - return - } - callback?(true) - } - } - - @MainActor func sendGetAccounts(callback: (([HotlineAccount]) -> Void)? = nil) { - let t = HotlineTransaction(type: .getAccounts) - - self.sendPacket(t) { reply, err in - guard err == nil else { - callback?([]) - return - } - - let accountFields = reply.getFieldList(type: .data) - - var accounts: [HotlineAccount] = [] - for data in accountFields { - accounts.append(data.getAcccount()) - } - - accounts.sort { $0.login < $1.login } - - callback?(accounts) - } - } - - @MainActor func sendGetNewsArticles(path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil) { - var t = HotlineTransaction(type: .getNewsArticleNameList) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleListData) else { - callback?([]) - return - } - - var articles: [HotlineNewsArticle] = [] - let newsList = articleData.getNewsList() - for art in newsList.articles { - var blah = art - blah.path = path - articles.append(blah) - } - - callback?(articles) - } - } - - @MainActor func sendGetFileList(path: [String] = [], suppressErrors: Bool = false, callback: (([HotlineFile]) -> Void)? = nil) { - var t = HotlineTransaction(type: .getFileNameList) - if !path.isEmpty { - t.setFieldPath(type: .filePath, val: path) - } - - self.sendPacket(t, suppressErrors: suppressErrors) { reply, err in - guard err == nil else { - callback?([]) - return - } - - var files: [HotlineFile] = [] - for fi in reply.getFieldList(type: .fileNameWithInfo) { - let file = fi.getFile() - file.path = path + [file.name] - files.append(file) - } - - callback?(files) - } - } - - @MainActor func sendDeleteFile(name fileName: String, path filePath: [String], callback: ((Bool) -> Void)? = nil) { - var t = HotlineTransaction(type: .deleteFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - self.sendPacket(t) { reply, err in - callback?(err == nil) - } - } - - @MainActor func sendGetFileInfo(name fileName: String, path filePath: [String], callback: ((FileDetails?) -> Void)? = nil) { - var t = HotlineTransaction(type: .getFileInfo) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = reply.getField(type: .fileTypeString)?.getString(), - let _ = reply.getField(type: .fileTypeString)?.getString(), - let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), - let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) - else { - callback?(nil) - return - } - - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - - // Comment field is not included for if no comment present - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - callback?(FileDetails(name: fileName, path: filePath, size: fileSize, comment: fileComment, type: fileType, creator: fileCreator, - created: fileCreateDate, modified: fileModifyDate)) - } - } - - - @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) { - var t = HotlineTransaction(type: .newUser) - - t.setFieldString(type: .userName, val: name) - t.setFieldEncodedString(type: .userLogin, val: login) - t.setFieldUInt64(type: .userAccess, val: access) - - if let password { - t.setFieldEncodedString(type: .userPassword, val: password) - } - - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendSetUser(name: String, login: String, newLogin: String?, password: String?, access: uint64) { - var t = HotlineTransaction(type: .setUser) - t.setFieldString(type: .userName, val: name) - t.setFieldUInt64(type: .userAccess, val: access) - - if let newLogin { - t.setFieldEncodedString(type: .data, val: login) - t.setFieldEncodedString(type: .userLogin, val: newLogin) - } else { - t.setFieldEncodedString(type: .userLogin, val: login) - } - - // In the setUser transaction, there are 3 possibilities for the password field: - // 1. If the password was not modified, the password field is sent with a zero byte. - if password == nil { - t.setFieldUInt8(type: .userPassword, val: 0) - } - - // 2. If the transaction should update the password, the password field is sent with the new password. - if let password, password != "" { - t.setFieldEncodedString(type: .userPassword, val: password) - } - - // 3) If the transaction should remove the password, the password field is omitted from the transaction. - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendDeleteUser(login: String) { - var t = HotlineTransaction(type: .deleteUser) - t.setFieldEncodedString(type: .userLogin, val: login) - - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendSetFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - var t = HotlineTransaction(type: .setFileInfo) - t.setFieldString(type: .fileName, val: fileName, encoding: encoding) - t.setFieldPath(type: .filePath, val: filePath) - - if fileNewName != nil { - t.setFieldString(type: .fileNewName, val: fileNewName!, encoding: encoding) - } - - if comment != nil { - t.setFieldString(type: .fileComment, val: comment!, encoding: encoding) - } - - self.sendPacket(t) - } - - @MainActor func sendDownloadFile(name fileName: String, path filePath: [String], preview: Bool = false, callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { - var t = HotlineTransaction(type: .downloadFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - if preview { - t.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil, nil, nil) - return - } - - let transferFileSizeField = reply.getField(type: .fileSize) - let transferFileSize = transferFileSizeField?.getInteger() - let transferWaitingCountField = reply.getField(type: .waitingCount) - let transferWaitingCount = transferWaitingCountField?.getInteger() - - callback?(true, referenceNumber, transferSize, transferFileSize ?? transferSize, transferWaitingCount) - } - } - - @MainActor func sendUploadFile(name fileName: String, path filePath: [String], callback: ((Bool, UInt32?) -> Void)? = nil) { - var t = HotlineTransaction(type: .uploadFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil) - return - } - - callback?(true, referenceNumber) - } - } - - @MainActor func sendDownloadFolder(name folderName: String, path folderPath: [String], callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { - var t = HotlineTransaction(type: .downloadFolder) - t.setFieldString(type: .fileName, val: folderName) - t.setFieldPath(type: .filePath, val: folderPath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil, nil, nil) - return - } - - let folderItemCountField = reply.getField(type: .folderItemCount) - let folderItemCount = folderItemCountField?.getInteger() - let transferWaitingCountField = reply.getField(type: .waitingCount) - let transferWaitingCount = transferWaitingCountField?.getInteger() - - callback?(true, referenceNumber, transferSize, folderItemCount, transferWaitingCount) - } - } - - @MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) { - let t = HotlineTransaction(type: .downloadBanner) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil) - return - } - - callback?(true, referenceNumber, transferSize) - } - } - - @MainActor private func sendKeepAlive() { - print("HotlineClient: Sending keep alive") - if let v = self.serverVersion, v >= 185 { - let t = HotlineTransaction(type: .connectionKeepAlive) - self.sendPacket(t) - } - else { - let t = HotlineTransaction(type: .getUserNameList) - self.sendPacket(t) - } - } - - - // MARK: - Utility - - @MainActor private func updateConnectionStatus(_ status: HotlineClientStatus) { - self.connectionStatus = status - self.delegate?.hotlineStatusChanged(status: status) - } - -} diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 30fe694..156b2b0 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -284,1837 +284,3 @@ struct HotlineFileInfoFork { return data } } - - -//protocol HotlineTransferDelegate: AnyObject { -// @MainActor func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) -//} - -//protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) -// @MainActor func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) -//} - -//protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) -//} - -//protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) -//} - -//protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) -// @MainActor func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) -//} - -//enum HotlineFileTransferStage: Int { -// case fileHeader = 1 -// case fileForkHeader = 2 -// case fileInfoFork = 3 -// case fileDataFork = 4 -// case fileResourceFork = 5 -// case fileUnsupportedFork = 6 -//} - -//enum HotlineFileUploadStage: Int { -// case magic = 1 -// case fileHeader = 2 -// case fileInfoForkHeader = 3 -// case fileInfoFork = 4 -// case fileDataForkHeader = 5 -// case fileDataFork = 6 -// case fileResourceForkHeader = 7 -// case fileResourceFork = 8 -// case fileComplete = 9 -//} - -//enum HotlineFolderDownloadStage: Int { -// case itemHeader = 0 // Read 2-byte length + item header -// case waitingForFileSize = 1 // Read 4-byte file size before FILP -// case fileHeader = 2 -// case fileForkHeader = 3 -// case fileInfoFork = 4 -// case fileDataFork = 5 -// case fileResourceFork = 6 -// case fileUnsupportedFork = 7 -//} - - -// MARK: - - -//class HotlineFileUploadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// weak var delegate: HotlineFileUploadClientDelegate? = nil -// -// private var connection: NWConnection? -// private var stage: HotlineFileUploadStage = .magic -// private var payloadSize: UInt32 = 0 -// private let fileURL: URL -// private let fileResourceURL: URL? -// private var fileHandle: FileHandle? = nil -// private var bytesSent: Int = 0 -// private let infoForkData: Data -// private let dataForkSize: UInt32 -// private let resourceForkSize: UInt32 -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) { -// guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { -// return nil -// } -// -// guard let infoFork = HotlineFileInfoFork(file: fileURL) else { -// return nil -// } -// -// guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { -// return nil -// } -// -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.stage = .magic -// self.payloadSize = UInt32(payloadSize) -// self.fileURL = fileURL -// self.infoForkData = infoFork.data() -// self.dataForkSize = forkSizes.dataForkSize -// self.resourceForkSize = forkSizes.resourceForkSize -// if forkSizes.resourceForkSize > 0 { -// self.fileResourceURL = fileURL.urlForResourceFork() -// } -// else { -// self.fileResourceURL = nil -// } -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// let _ = self.fileURL.startAccessingSecurityScopedResource() -// let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() -// -// self.bytesSent = 0 -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// self.invalidate() -// -// print("HotlineFileUploadClient: Cancelled upload") -// } -// -// private func connect() { -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// self?.status = .connected -// self?.stage = .magic -// self?.send() -// case .waiting(let err): -// print("HotlineFileClient: Waiting", err) -// case .cancelled: -// print("HotlineFileClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFileClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFileClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFileClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToUpload) -// case .completing: -// print("HotlineFileClient: Completed.") -// self?.invalidate() -// self?.status = .completed -// DispatchQueue.main.async { [weak self] in -// if let s = self { -// s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) -// } -// } -// case .completed: -// self?.invalidate() -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// private func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.stage = .magic -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileURL.stopAccessingSecurityScopedResource() -// self.fileResourceURL?.stopAccessingSecurityScopedResource() -// } -// -// private func sendComplete() { -// guard let c = self.connection else { -// self.invalidate() -// print("HotlineFileUploadClient: invalid connection to send data.") -// return -// } -// -// self.status = .completing -// -// c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in -// })) -// } -// -// private func sendFileData(_ data: Data) { -// guard let c = self.connection else { -// self.invalidate() -// -// print("HotlineFileUploadClient: invalid connection to send data.") -// return -// } -// -// let dataSent: Int = data.count -// c.send(content: data, completion: .contentProcessed({ [weak self] error in -// guard let client = self, -// error == nil else { -// self?.status = .failed(.failedToConnect) -// self?.invalidate() -// return -// } -// -// -// client.bytesSent += dataSent -// client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) -// -// client.send() -// })) -// } -// -// private func send() { -// guard let _ = self.connection else { -// self.invalidate() -// print("HotlineFileUploadClient: Invalid connection to send.") -// return -// } -// -// switch self.stage { -// case .magic: -// print("Upload: Starting upload for \(self.fileURL)") -// print("Upload: Sending magic") -// self.status = .progress(0.0) -// -// let magicData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// self.payloadSize -// UInt32.zero -// } -// // var magicData = Data() -// // magicData.appendUInt32("HTXF".fourCharCode()) -// // magicData.appendUInt32(self.referenceNumber) -// // magicData.appendUInt32(self.payloadSize) -// // magicData.appendUInt32(0) -// self.stage = .fileHeader -// self.sendFileData(magicData) -// -// case .fileHeader: -// print("Upload: Sending file header") -// if let header = HotlineFileHeader(file: self.fileURL) { -// self.stage = .fileInfoForkHeader -// self.sendFileData(header.data()) -// } -// -// case .fileInfoForkHeader: -// print("Upload: Sending info fork header") -// let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) -// self.stage = .fileInfoFork -// self.sendFileData(header.data()) -// -// case .fileInfoFork: -// print("Upload: Sending info fork") -// self.stage = .fileDataForkHeader -// self.sendFileData(self.infoForkData) -// -// case .fileDataForkHeader: -// guard self.dataForkSize > 0 else { -// print("Upload: Data fork empty, skipping to resource fork") -// self.stage = .fileResourceForkHeader -// fallthrough -// } -// -// do { -// let fh = try FileHandle(forReadingFrom: self.fileURL) -// self.fileHandle = fh -// self.stage = .fileDataFork -// -// let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) -// -// print("Upload: Sending data fork header \(self.dataForkSize)") -// self.sendFileData(header.data()) -// } -// catch { -// print("Upload: Error opening data fork", error) -// self.invalidate() -// return -// } -// -// case .fileDataFork: -// guard self.dataForkSize > 0, -// let fh = self.fileHandle else { -// print("Upload: Data fork empty, skipping to resource fork") -// self.stage = .fileResourceForkHeader -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// do { -// let fileData = try fh.read(upToCount: 4 * 1024) -// if fileData == nil || fileData?.isEmpty == true { -// print("Upload: Finished data fork") -// self.stage = .fileResourceForkHeader -// try? fh.close() -// self.fileHandle = nil -// fallthrough -// } -// -// print("Upload: Sending data Fork \(String(describing: fileData?.count))") -// self.sendFileData(fileData!) -// } -// catch { -// self.invalidate() -// print("Upload: Error reading data fork", error) -// return -// } -// -// case .fileResourceForkHeader: -// guard self.resourceForkSize > 0, -// let resourceURL = self.fileResourceURL else { -// print("Upload: Skipping resource fork header") -// self.stage = .fileComplete -// fallthrough -// } -// -// print("Upload: Sending resource fork header") -// guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { -// print("Upload: Error reading resource fork") -// self.invalidate() -// return -// } -// -// let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) -// -// self.fileHandle = fh -// self.stage = .fileResourceFork -// self.sendFileData(header.data()) -// -// case .fileResourceFork: -// guard self.resourceForkSize > 0, -// let fh = self.fileHandle else { -// print("Upload: Resource fork empty, skipping to completion") -// self.stage = .fileComplete -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// do { -// let resourceData = try fh.read(upToCount: 4 * 1024) -// if resourceData == nil || resourceData?.isEmpty == true { -// print("Upload: Finished resource fork") -// self.stage = .fileComplete -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// print("Upload: Sending resource fork \(String(describing: resourceData?.count))") -// self.sendFileData(resourceData!) -// } -// catch { -// self.invalidate() -// print("Upload: Error reading resource fork", error) -// return -// } -// break -// -// case .fileComplete: -// print("Upload: Complete!") -// self.sendComplete() -// } -// } -//} - -// MARK: - -// -//class HotlineFilePreviewClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// let referenceDataSize: UInt32 -// -// weak var delegate: HotlineFilePreviewClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private var downloadTask: Task<Void, Never>? -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// } -// -// deinit { -// self.downloadTask?.cancel() -// } -// -// func start() { -// guard status == .unconnected else { -// return -// } -// -// self.downloadTask = Task { -// await self.download() -// } -// } -// -// func cancel() { -// self.downloadTask?.cancel() -// self.downloadTask = nil -// self.delegate = nil -// -// print("HotlineFilePreviewClient: Cancelled preview transfer") -// } -// -// private func download() async { -// await MainActor.run { self.status = .connecting } -// -// do { -// // Connect to file transfer server (already includes +1 in serverPort from init) -// let socket = try await NetSocketNew.connect( -// host: self.serverAddress, -// port: self.serverPort, -// tls: .disabled -// ) -// defer { Task { await socket.close() } } -// -// await MainActor.run { self.status = .connected } -// -// // Send magic header -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero -// UInt32.zero -// } -// try await socket.write(headerData) -// -// await MainActor.run { self.status = .progress(0.0) } -// -// // Download file data with progress updates -// let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in -// Task { @MainActor [weak self] in -// guard let self else { return } -// self.status = .progress(Double(current) / Double(total)) -// } -// } -// -// print("HotlineFilePreviewClient: Complete") -// await MainActor.run { self.status = .completed } -// -// // Notify delegate -// let reference = self.referenceNumber -// await MainActor.run { -// self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) -// } -// -// } catch is CancellationError { -// // Already handled in cancel() -// return -// } catch { -// print("HotlineFilePreviewClient: Download failed: \(error)") -// -// let failureStatus: HotlineTransferStatus = await MainActor.run { self.status } -// -// if failureStatus == .connecting { -// await MainActor.run { self.status = .failed(.failedToConnect) } -// } else { -// await MainActor.run { self.status = .failed(.failedToDownload) } -// } -// } -// } -// -// // status mutations are centralized via MainActor.run calls above -//} - -// MARK: - Async Preview Client (New) - -//enum HotlineFilePreviewClientNew { -// typealias ProgressHandler = @Sendable (NetSocketNew.FileProgress) -> Void -// -// struct Configuration { -// /// Chunk size used when reading the preview data stream. -// var chunkSize: Int = 256 * 1024 -// } -// -// /// Download preview data directly from the transfer server. -// /// - Parameters: -// /// - address: Hostname/IP of the Hotline server (preview runs on port+1). -// /// - port: Hotline base port. -// /// - reference: Transfer reference number returned by the server. -// /// - size: Expected payload size in bytes. -// /// - configuration: Optional tuning knobs (currently chunk size). -// /// - progress: Optional callback invoked as bytes stream in. -// /// - Returns: Raw preview data. -// static func download( -// address: String, -// port: UInt16, -// reference: UInt32, -// size: UInt32, -// configuration: Configuration = .init(), -// progress: ProgressHandler? = nil -// ) async throws -> Data { -// let host = NWEndpoint.Host(address) -// guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { -// throw NetSocketError.invalidPort -// } -// -// let socket = try await NetSocketNew.connect(host: host, port: transferPort, tls: .disabled) -// defer { Task { await socket.close() } } -// -// let header = Data(endian: .big) { -// "HTXF".fourCharCode() -// reference -// UInt32.zero -// UInt32.zero -// } -// -// try await socket.write(header) -// -// guard size > 0 else { -// return Data() -// } -// -// return try await socket.read(Int(size), chunkSize: configuration.chunkSize) { current, total in -// guard let progress else { return } -// let info = NetSocketNew.FileProgress(sent: current, total: total) -// progress(info) -// } -// } -//} - -// MARK: - - -//class HotlineFileDownloadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// private var connection: NWConnection? -// private var transferStage: HotlineFileTransferStage = .fileHeader -// -// weak var delegate: HotlineFileDownloadClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private let referenceDataSize: UInt32 -// private var fileBytes = Data() -// private var fileResourceBytes = Data() -// -// private var fileHeader: HotlineFileHeader? = nil -// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil -// private var fileCurrentForkBytesLeft: Int = 0 -// private var fileInfoFork: HotlineFileInfoFork? = nil -// private var fileHandle: FileHandle? = nil -// private var filePath: String? = nil -// private var fileBytesTransferred: Int = 0 -// private var fileProgress: Progress -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// self.transferStage = .fileHeader -// self.fileProgress = Progress(totalUnitCount: Int64(size)) -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// self.filePath = nil -// self.connect() -// } -// -// func start(to fileURL: URL) { -// guard self.status == .unconnected else { -// return -// } -// -// self.filePath = fileURL.path -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// // Close file before we try to potentionally delete it. -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// if let downloadPath = self.filePath { -// print("HotlineFileClient: Deleting file fragment at", downloadPath) -// if FileManager.default.isDeletableFile(atPath: downloadPath) { -// try? FileManager.default.removeItem(atPath: downloadPath) -// } -// self.filePath = nil -// } -// -// self.invalidate() -// -// print("HotlineFileClient: Cancelled transfer") -// } -// -// private func connect() { -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// self?.status = .connected -// self?.sendMagic() -// case .waiting(let err): -// print("HotlineFileClient: Waiting", err) -// case .cancelled: -// print("HotlineFileClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFileClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFileClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFileClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToDownload) -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.fileBytes = Data() -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileProgress.unpublish() -// } -// -// private func sendMagic() { -// guard let c = connection, self.status == .connected else { -// self.invalidate() -// print("HotlineFileClient: invalid connection to send header.") -// return -// } -// -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero -// UInt32.zero -// } -// -// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// self.status = .failed(.failedToConnect) -// self.invalidate() -// return -// } -// -// self.status = .progress(0.0) -// self.receiveFile() -// }) -// } -// -// private func receiveFile() { -// guard let c = self.connection else { -// return -// } -// -// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// self.status = .failed(.failedToDownload) -// self.invalidate() -// return -// } -// -// if let newData = data, !newData.isEmpty { -// self.fileBytesTransferred += newData.count -// self.fileBytes.append(newData) -// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) -// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) -// } -// -// // See if we need header data still. -// var keepProcessing = false -// repeat { -// keepProcessing = false -// -// switch self.transferStage { -// case .fileHeader: -// if let header = HotlineFileHeader(from: self.fileBytes) { -// self.fileBytes.removeSubrange(0..<HotlineFileHeader.DataSize) -// self.fileHeader = header -// self.transferStage = .fileForkHeader -// keepProcessing = true -// } -// case .fileForkHeader: -// if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { -// // let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes) -// self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize) -// self.fileCurrentForkHeader = forkHeader -// self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize) -// -// if forkHeader.isInfoFork { -// print("HotlineFileClient: Downloading info fork") -// self.transferStage = .fileInfoFork -// } -// else if forkHeader.isDataFork { -// print("HotlineFileClient: Downloading data fork") -// self.transferStage = .fileDataFork -// } -// else if forkHeader.isResourceFork { -// print("HotlineFileClient: Downloading resource fork") -// self.fileResourceBytes = Data() -// self.transferStage = .fileResourceFork -// } -// else { -// print("HotlineFileClient: Downloading unsupported fork") -// self.transferStage = .fileUnsupportedFork -// } -// -// keepProcessing = true -// } -// case .fileInfoFork: -// if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize { -// let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize)) -// if let infoFork = HotlineFileInfoFork(from: infoForkData) { -// self.fileInfoFork = infoFork -// -// self.fileBytes.removeSubrange(0..<infoFork.headerSize) -// -// self.transferStage = .fileForkHeader -// self.fileCurrentForkBytesLeft -= Int(infoForkDataSize) -// self.fileCurrentForkHeader = nil -// -// print("INFO FORK STUFF:", infoFork) -// -// // Create file in Downloads folder if we don't have a destination already. -// if !self.prepareDownloadFile(name: infoFork.name) { -// print("FAILED TO CREATE FILE ON DISK") -// } -// -// let reference = self.referenceNumber -// DispatchQueue.main.async { -// self.delegate?.hotlineFileDownloadReceivedInfo(client: self, reference: reference, info: infoFork) -// } -// } -// -// keepProcessing = true -// } -// case .fileDataFork: -// if self.fileBytes.count > 0 { -// if let f = self.fileHandle { -// do { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) -// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) -// -// self.transferStage = .fileForkHeader -// self.fileCurrentForkBytesLeft = 0 -// self.fileCurrentForkHeader = nil -// -// keepProcessing = true -// } -// else { -// self.fileCurrentForkBytesLeft -= dataToWrite.count -// self.fileBytes = Data() -// } -// -// try f.write(contentsOf: dataToWrite) -// } -// catch { -// print("DOWNLOAD WRITE ERROR", error) -// } -// } -// } -// case .fileResourceFork: -// if self.fileBytes.count > 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) -// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) -// -// self.transferStage = .fileForkHeader -// self.fileCurrentForkBytesLeft = 0 -// self.fileCurrentForkHeader = nil -// -// keepProcessing = true -// } -// else { -// self.fileCurrentForkBytesLeft -= dataToWrite.count -// self.fileBytes = Data() -// } -// -// print("WRITING TO RESOURCE FORK", dataToWrite.count) -// -// self.fileResourceBytes.append(dataToWrite) -// } -// case .fileUnsupportedFork: -// if self.fileBytes.count > 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) -// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) -// -// self.transferStage = .fileForkHeader -// self.fileCurrentForkBytesLeft = 0 -// self.fileCurrentForkHeader = nil -// -// keepProcessing = true -// } -// else { -// self.fileCurrentForkBytesLeft -= dataToWrite.count -// self.fileBytes = Data() -// } -// } -// } -// } while keepProcessing -// -// if self.fileBytesTransferred < Int(self.referenceDataSize) { -// // If we still have more to download, then receive more data. -// self.receiveFile() -// return -// } -// else { -// self.invalidate() -// -// if self.fileResourceBytes.count > 0 { -// let _ = self.writeResourceFork() -// self.fileResourceBytes = Data() -// } -// -// self.status = .completed -// -// if let downloadPath = self.filePath { -// DispatchQueue.main.sync { -// print("POSTING DOWNLOAD FILE FINISHED", downloadPath) -// -// var downloadURL = URL(filePath: downloadPath) -// downloadURL.resolveSymlinksInPath() -// print("FINAL PATH", downloadURL.path) -// -// self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) -// -//#if os(macOS) -// // Bounce dock icon when download completes. Weird this is the only API to do so. -// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -//#endif -// } -// } -// } -// } -// } -// -// private func writeResourceFork() -> Bool { -// guard let filePath = self.filePath else { -// return false -// } -// -// var resolvedFileURL = URL(filePath: filePath) -// resolvedFileURL.resolveSymlinksInPath() -// -// let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") -// -// do { -// try self.fileResourceBytes.write(to: resourceFilePath) -// } -// catch { -// return false -// } -// -// return true -// } -// -// private func prepareDownloadFile(name: String) -> Bool { -// var filePath: String -// -// if self.filePath != nil { -// filePath = self.filePath! -// } -// else { -// let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] -// filePath = folderURL.generateUniqueFilePath(filename: name) -// } -// -// var fileAttributes: [FileAttributeKey: Any] = [:] -// if let creatorCode = self.fileInfoFork?.creator { -// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber -// } -// if let typeCode = self.fileInfoFork?.type { -// fileAttributes[.hfsTypeCode] = typeCode as NSNumber -// } -// if let createdDate = self.fileInfoFork?.createdDate { -// fileAttributes[.creationDate] = createdDate as NSDate -// } -// if let modifiedDate = self.fileInfoFork?.modifiedDate { -// fileAttributes[.modificationDate] = modifiedDate as NSDate -// } -// if let comment = self.fileInfoFork?.comment { -// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { -// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ -// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData -// ] -// } -// } -// -// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { -// if let h = FileHandle(forWritingAtPath: filePath) { -// self.filePath = filePath -// self.fileHandle = h -// self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() -// self.fileProgress.fileOperationKind = .downloading -// self.fileProgress.publish() -// return true -// } -// } -// -// return false -// } -//} - -// MARK: - - - -// MARK: - - -//class HotlineFolderDownloadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// private var connection: NWConnection? -// private var transferStage: HotlineFolderDownloadStage = .fileHeader -// -// weak var delegate: HotlineFolderDownloadClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private let referenceDataSize: UInt32 -// private let folderItemCount: Int -// private var fileBytes = Data() -// private var fileResourceBytes = Data() -// -// private var fileHeader: HotlineFileHeader? = nil -// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil -// private var fileCurrentForkBytesLeft: Int = 0 -// private var fileForksRemaining: Int = 0 -// private var currentFileSize: UInt32 = 0 // Size of current file from server -// private var currentFileBytesRead: Int = 0 // Bytes read for current file -// private var fileInfoFork: HotlineFileInfoFork? = nil -// private var fileHandle: FileHandle? = nil -// private var currentFilePath: String? = nil -// private var fileBytesTransferred: Int = 0 -// private var fileProgress: Progress -// -// private var currentItemNumber: Int = 0 -// private var completedItemCount: Int = 0 // Track actually completed files -// private var folderPath: String? = nil -// private var currentItemRelativePath: [String] = [] -// private var currentFileName: String? = nil -// -// private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// self.folderItemCount = itemCount -// self.transferStage = .fileHeader -// self.fileProgress = Progress(totalUnitCount: Int64(size)) -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// self.folderPath = nil -// self.connect() -// } -// -// func start(to folderURL: URL) { -// print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") -// guard self.status == .unconnected else { -// print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") -// return -// } -// -// self.folderPath = folderURL.path -// print("HotlineFolderDownloadClient: Calling connect()") -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// // Close file before we try to potentially delete it. -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// if let downloadPath = self.folderPath { -// print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) -// if FileManager.default.isDeletableFile(atPath: downloadPath) { -// try? FileManager.default.removeItem(atPath: downloadPath) -// } -// self.folderPath = nil -// } -// -// self.invalidate() -// -// print("HotlineFolderDownloadClient: Cancelled transfer") -// } -// -// private func connect() { -// print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// print("HotlineFolderDownloadClient: NWConnection created") -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// print("HotlineFolderDownloadClient: Connection ready!") -// self?.status = .connected -// self?.sendMagic() -// case .waiting(let err): -// print("HotlineFolderDownloadClient: Waiting", err) -// case .cancelled: -// print("HotlineFolderDownloadClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFolderDownloadClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFolderDownloadClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToDownload) -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.fileBytes = Data() -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileProgress.unpublish() -// } -// -// private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { -// // Need at least: type(2) + count(2) -// guard headerData.count >= 4, -// let type = headerData.readUInt16(at: 0), -// let count = headerData.readUInt16(at: 2) else { return nil } -// -// var ofs = 4 -// var comps: [String] = [] -// for _ in 0..<Int(count) { -// guard headerData.count >= ofs + 3 else { return nil } -// // per Hotline path encoding: reserved(2) then nameLen(1) then name -// ofs += 2 // reserved == 0 -// let nameLen = Int(headerData.readUInt8(at: ofs)!) -// ofs += 1 -// guard headerData.count >= ofs + nameLen else { return nil } -// let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) -// ofs += nameLen -// -// let name = String(data: nameData, encoding: .macOSRoman) -// ?? String(data: nameData, encoding: .utf8) -// ?? "" -// comps.append(name) -// } -// return (type, comps) -// } -// -// /// Find and align the buffer to the start of a FILP header. -// /// Returns the number of bytes dropped. -// @discardableResult -// private func alignBufferToFILP() -> Int { -// // We need at least the 4-byte magic to try -// guard self.fileBytes.count >= 4 else { return 0 } -// let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" -// if self.fileBytes.starts(with: magicData) { return 0 } -// -// if let r = self.fileBytes.firstRange(of: magicData) { -// let toDrop = r.lowerBound -// if toDrop > 0 { -// print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") -// self.fileBytes.removeSubrange(0..<toDrop) -// } -// return toDrop -// } -// -// // If we can't find FILP yet, wait for more data. -// return 0 -// } -// -// private func sendMagic() { -// print("HotlineFolderDownloadClient: sendMagic() called") -// guard let c = connection, self.status == .connected else { -// self.invalidate() -// print("HotlineFolderDownloadClient: invalid connection to send header.") -// return -// } -// -// // Folder transfer initial record: HTXF + ref + dataSize(0) + type(1) + rsvd(0) + action(NextFile) -// // Classic clients send a zero data size and append the action immediately after the header. -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero // data size = 0 (action appended implicitly) -// UInt16(1) // type = 1 (folder transfer) -// UInt16.zero // reserved = 0 -// UInt16(HotlineFolderAction.nextFile.rawValue) // action = 3 (next file) -// } -// -// print("HotlineFolderDownloadClient: Sending HTXF magic with reference: \(self.referenceNumber)") -// -// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in -// guard let self = self else { -// print("HotlineFolderDownloadClient: self is nil in sendMagic completion") -// return -// } -// -// guard error == nil else { -// print("HotlineFolderDownloadClient: Error sending magic: \(error!)") -// self.status = .failed(.failedToConnect) -// self.invalidate() -// return -// } -// -// print("HotlineFolderDownloadClient: Magic sent successfully, expecting item header") -// self.status = .progress(0.0) -// self.currentItemNumber = 0 -// self.transferStage = .itemHeader -// self.receiveFolder() -// }) -// } -// -// private func sendAction(_ action: HotlineFolderAction, resumeData: Data? = nil) { -// guard let c = connection else { -// self.invalidate() -// print("HotlineFolderDownloadClient: invalid connection to send action.") -// return -// } -// -// var actionData = Data(endian: .big) { -// UInt16(action.rawValue) -// } -// -// if let resume = resumeData, !resume.isEmpty { -// actionData.append(Data(endian: .big) { UInt16(resume.count) }) -// actionData.append(resume) -// } -// -// print("HotlineFolderDownloadClient: Sending action: \(action)") -// -// c.send(content: actionData, completion: .contentProcessed { [weak self] (error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// print("HotlineFolderDownloadClient: Error sending action: \(error!)") -// self.status = .failed(.failedToConnect) -// self.invalidate() -// return -// } -// -// print("HotlineFolderDownloadClient: Action sent successfully") -// // Don't call receiveFolder() here - the main receive loop handles it -// }) -// } -// -// private func receiveFolder() { -// print("HotlineFolderDownloadClient: receiveFolder() called, status: \(self.status)") -// guard let c = self.connection else { -// print("HotlineFolderDownloadClient: No connection in receiveFolder") -// return -// } -// -// print("HotlineFolderDownloadClient: Connection state: \(c.state)") -// print("HotlineFolderDownloadClient: About to call c.receive()") -// -// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in -// print("HotlineFolderDownloadClient: Receive completion handler called! isComplete: \(isComplete), error: \(String(describing: error)), dataCount: \(data?.count ?? 0)") -// -// guard let self = self else { -// print("HotlineFolderDownloadClient: self is nil in receiveFolder") -// return -// } -// -// if let error = error { -// if self.status == .completed, -// case .posix(let posixError) = error, -// posixError == .ENODATA { -// print("HotlineFolderDownloadClient: Ignoring ENODATA after completion") -// return -// } -// -// print("HotlineFolderDownloadClient: Error in receiveFolder: \(error)") -// self.status = .failed(.failedToDownload) -// self.invalidate() -// return -// } -// -// if let newData = data, !newData.isEmpty { -// print("HotlineFolderDownloadClient: Received \(newData.count) bytes (total: \(self.fileBytesTransferred + newData.count)/\(self.referenceDataSize))") -// self.fileBytesTransferred += newData.count -// self.fileBytes.append(newData) -// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) -// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) -// } -// else { -// print("HotlineFolderDownloadClient: Received empty or nil data, isComplete: \(isComplete)") -// } -// -// // Process received data -// var keepProcessing = false -// repeat { -// keepProcessing = false -// -// switch self.transferStage { -// case .itemHeader: -// // Need 2-byte length + header data -// guard self.fileBytes.count >= 2 else { break } -// let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) -// guard self.fileBytes.count >= 2 + headerLen else { break } -// -// let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) -// -// guard let parsed = parseItemHeaderPath(headerData) else { -// print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") -// break -// } -// -// // Consume the header from the buffer -// self.fileBytes.removeSubrange(0..<(2 + headerLen)) -// -// let itemType = parsed.type -// let comps = parsed.components -// let joinedPath = comps.joined(separator: "/") -// print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") -// -// guard !comps.isEmpty else { -// print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// -// if itemType == 1 { -// // Folder entries: create the directory locally and continue. -// self.currentItemRelativePath = comps -// -// if let base = self.folderPath { -// var dir = base -// for c in comps { dir = (dir as NSString).appendingPathComponent(c) } -// do { -// try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) -// print("HotlineFolderDownloadClient: Created folder at \(dir)") -// } catch { -// print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") -// } -// } -// -// self.completedItemCount += 1 -// -// if self.completedItemCount >= self.folderItemCount { -// self.handleAllItemsDownloaded() -// return -// } -// -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// else if itemType == 0 { -// // File entries include the full path; split parent components from filename. -// self.currentItemRelativePath = Array(comps.dropLast()) -// self.currentFileName = comps.last -// -// self.transferStage = .waitingForFileSize -// print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") -// self.sendAction(.sendFile) -// -// if self.fileBytes.count >= 4 { -// keepProcessing = true -// } -// break -// } -// else { -// print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// -// case .waitingForFileSize: -// // Read 4-byte file size that comes before FILP in folder mode -// guard self.fileBytes.count >= 4 else { break } -// let fileSize = self.fileBytes.readUInt32(at: 0)! -// self.fileBytes.removeSubrange(0..<4) -// -// print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") -// -// self.currentFileSize = fileSize -// self.currentFileBytesRead = 0 -// -// // Align to FILP boundary before decoding the file header -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { -// // These bytes were in the stream for this file but not part of FILP. -// // Keep byte-accounting consistent by shrinking the expected size. -// if self.currentFileSize >= UInt32(dropped) { -// self.currentFileSize -= UInt32(dropped) -// } else { -// // Defensive: if weird, treat as zero-size to avoid underflow -// self.currentFileSize = 0 -// } -// } -// -// self.transferStage = .fileHeader -// keepProcessing = true -// -// case .fileHeader: -// // Make sure we're actually at a FILP header -// if self.fileBytes.count >= HotlineFileHeader.DataSize { -// // If the 4-byte magic doesn't match, try one more resync here -// if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { -// if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } -// } -// // If still not aligned or not enough bytes, wait for more data -// guard self.fileBytes.count >= HotlineFileHeader.DataSize, -// self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } -// } -// -// if let header = HotlineFileHeader(from: self.fileBytes) { -// // Sanity gate: version and fork count -// if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { -// print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") -// // Try resync and wait for more data -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } -// break -// } -// -// self.fileBytes.removeSubrange(0..<HotlineFileHeader.DataSize) -// self.currentFileBytesRead += HotlineFileHeader.DataSize -// self.fileHeader = header -// self.fileForksRemaining = Int(header.forkCount) -// print("HotlineFolderDownloadClient: File has \(header.forkCount) forks") -// self.transferStage = .fileForkHeader -// keepProcessing = true -// } -// } -// case .fileForkHeader: -// let allForksConsumed = self.fileForksRemaining == 0 && self.fileCurrentForkHeader == nil && self.fileCurrentForkBytesLeft == 0 -// -// if allForksConsumed { -// if self.currentFileBytesRead != Int(self.currentFileSize) { -// self.currentFileSize = UInt32(min(Int.max, self.currentFileBytesRead)) -// } -// -// print("HotlineFolderDownloadClient: File complete (consumed all forks, bytes read: \(self.currentFileBytesRead)/\(self.currentFileSize))") -// -// self.finishCurrentFile() -// self.completedItemCount += 1 -// -// // Check if we've completed all items -// if self.completedItemCount >= self.folderItemCount { -// self.handleAllItemsDownloaded() -// return -// } -// -// // More files to download -// // Check if server has pipelined all remaining data (we've received more than expected total) -// print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") -// self.transferStage = .itemHeader -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// } -// // File not complete - try to read next fork header -// else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { -// // Quick validation: first fork must be INFO, and sizes must be plausible -// let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) -// let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) -// let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork -// -// if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { -// print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } -// break -// } -// -// self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize) -// self.currentFileBytesRead += HotlineFileForkHeader.DataSize -// self.fileCurrentForkHeader = forkHeader -// self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize) -// self.fileForksRemaining -= 1 // Decrement fork count -// -// print("HotlineFolderDownloadClient: Read fork header (type: \(String(format: "0x%08X", forkHeader.forkType)), size: \(forkHeader.dataSize) bytes, \(self.fileForksRemaining) forks remaining)") -// -// // Handle zero-length forks immediately -// if forkHeader.dataSize == 0 { -// print("HotlineFolderDownloadClient: Skipping zero-length fork") -// self.fileCurrentForkHeader = nil -// self.fileCurrentForkBytesLeft = 0 -// // Stay in .fileForkHeader to read next fork or complete file -// keepProcessing = true -// } -// else if forkHeader.isInfoFork { -// print("HotlineFolderDownloadClient: Downloading info fork for item \(self.currentItemNumber + 1)/\(self.folderItemCount)") -// self.transferStage = .fileInfoFork -// keepProcessing = true -// } -// else if forkHeader.isDataFork { -// print("HotlineFolderDownloadClient: Downloading data fork") -// self.transferStage = .fileDataFork -// keepProcessing = true -// } -// else if forkHeader.isResourceFork { -// print("HotlineFolderDownloadClient: Downloading resource fork") -// self.fileResourceBytes = Data() -// self.transferStage = .fileResourceFork -// keepProcessing = true -// } -// else { -// print("HotlineFolderDownloadClient: Downloading unsupported fork (type: \(String(format: "0x%08X", forkHeader.forkType)), size: \(forkHeader.dataSize) bytes)") -// self.transferStage = .fileUnsupportedFork -// keepProcessing = true -// } -// } -// // Not enough data yet for next fork header - wait for more data -// -// case .fileInfoFork: -// if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize { -// let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize)) -// if let infoFork = HotlineFileInfoFork(from: infoForkData) { -// self.fileInfoFork = infoFork -// -// self.fileBytes.removeSubrange(0..<infoFork.headerSize) -// self.currentFileBytesRead += infoFork.headerSize -// -// self.fileCurrentForkBytesLeft -= Int(infoForkDataSize) -// self.fileCurrentForkHeader = nil -// -// print("HotlineFolderDownloadClient: Received info fork for \(infoFork.name)") -// -// // Create the file on disk using the filename we extracted from the item header -// if !self.prepareDownloadFile(name: self.currentFileName ?? infoFork.name) { -// print("HotlineFolderDownloadClient: Failed to create file on disk") -// } -// -// self.currentItemNumber += 1 -// -// let reference = self.referenceNumber -// let fileName = infoFork.name -// let itemNum = self.currentItemNumber -// let totalItems = self.folderItemCount -// DispatchQueue.main.async { -// self.delegate?.hotlineFolderDownloadReceivedFileInfo(client: self, reference: reference, fileName: fileName, itemNumber: itemNum, totalItems: totalItems) -// } -// -// // Info fork complete, check for next fork -// self.transferStage = .fileForkHeader -// keepProcessing = true -// } -// } -// case .fileDataFork: -// if self.fileBytes.count > 0 { -// if let f = self.fileHandle { -// do { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) -// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) -// self.currentFileBytesRead += dataToWrite.count -// -// self.fileCurrentForkBytesLeft = 0 -// self.fileCurrentForkHeader = nil -// -// // Write the final chunk of data -// try f.write(contentsOf: dataToWrite) -// -// print("HotlineFolderDownloadClient: Data fork complete") -// -// self.transferStage = .fileForkHeader -// keepProcessing = true -// } -// else { -// self.fileCurrentForkBytesLeft -= dataToWrite.count -// self.currentFileBytesRead += dataToWrite.count -// self.fileBytes = Data() -// -// // Write partial data -// try f.write(contentsOf: dataToWrite) -// } -// } -// catch { -// print("HotlineFolderDownloadClient: Download write error", error) -// } -// } -// } -// case .fileResourceFork: -// if self.fileBytes.count > 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) -// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) -// self.currentFileBytesRead += dataToWrite.count -// -// self.fileCurrentForkBytesLeft = 0 -// self.fileCurrentForkHeader = nil -// -// print("HotlineFolderDownloadClient: Writing to resource fork", dataToWrite.count) -// self.fileResourceBytes.append(dataToWrite) -// -// print("HotlineFolderDownloadClient: Resource fork complete") -// -// self.transferStage = .fileForkHeader -// keepProcessing = true -// } -// else { -// self.fileCurrentForkBytesLeft -= dataToWrite.count -// self.currentFileBytesRead += dataToWrite.count -// self.fileBytes = Data() -// -// print("HotlineFolderDownloadClient: Writing to resource fork", dataToWrite.count) -// self.fileResourceBytes.append(dataToWrite) -// } -// } -// case .fileUnsupportedFork: -// if self.fileBytes.count > 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) -// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) -// self.currentFileBytesRead += dataToWrite.count -// -// self.fileCurrentForkBytesLeft = 0 -// self.fileCurrentForkHeader = nil -// -// print("HotlineFolderDownloadClient: Unsupported fork complete (discarded \(dataToWrite.count) bytes)") -// -// self.transferStage = .fileForkHeader -// keepProcessing = true -// } -// else { -// self.fileCurrentForkBytesLeft -= dataToWrite.count -// self.currentFileBytesRead += dataToWrite.count -// print("HotlineFolderDownloadClient: Unsupported fork partial data (discarded \(dataToWrite.count) bytes, \(self.fileCurrentForkBytesLeft) bytes remaining)") -// self.fileBytes = Data() -// } -// } -// } -// } while keepProcessing -// -// // Continue receiving data - completion is handled in .fileForkHeader when all items are done -// if self.connection != nil && self.status != .completed { -// self.receiveFolder() -// } -// } -// } -// -// private func finishCurrentFile() { -// // Close current file handle -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// // Write resource fork if present -// if self.fileResourceBytes.count > 0 { -// let _ = self.writeResourceFork() -// self.fileResourceBytes = Data() -// } -// -// self.fileCurrentForkHeader = nil -// self.fileCurrentForkBytesLeft = 0 -// self.fileForksRemaining = 0 -// self.currentFileBytesRead = 0 -// self.currentFileSize = 0 -// self.currentFilePath = nil -// self.fileInfoFork = nil -// self.fileHeader = nil -// self.currentFileName = nil -// } -// -// private func handleAllItemsDownloaded() { -// guard self.status != .completed else { -// return -// } -// -// print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") -// -// self.invalidate() -// self.status = .completed -// -// if let downloadPath = self.folderPath { -// DispatchQueue.main.sync { -// print("HotlineFolderDownloadClient: Folder download complete", downloadPath) -// -// var downloadURL = URL(filePath: downloadPath) -// downloadURL.resolveSymlinksInPath() -// -// self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) -// -//#if os(macOS) -// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -//#endif -// } -// } -// } -// -// private func writeResourceFork() -> Bool { -// guard let filePath = self.currentFilePath else { -// return false -// } -// -// var resolvedFileURL = URL(filePath: filePath) -// resolvedFileURL.resolveSymlinksInPath() -// -// let resourceFilePath = resolvedFileURL.urlForResourceFork() -// -// do { -// try self.fileResourceBytes.write(to: resourceFilePath) -// } -// catch { -// return false -// } -// -// return true -// } -// -// private func prepareDownloadFile(name: String) -> Bool { -// var filePath: String -// -// if self.folderPath != nil { -// // Build the full path including subfolders -// var fullPath = self.folderPath! -// for component in self.currentItemRelativePath { -// fullPath = (fullPath as NSString).appendingPathComponent(component) -// } -// -// let folderURL = URL(filePath: fullPath) -// -// // Create folder if it doesn't exist -// if !FileManager.default.fileExists(atPath: folderURL.path) { -// do { -// try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) -// } -// catch { -// print("HotlineFolderDownloadClient: Failed to create folder", error) -// return false -// } -// } -// -// filePath = folderURL.appendingPathComponent(name).path -// print("HotlineFolderDownloadClient: Creating file at \(filePath)") -// } -// else { -// let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] -// filePath = downloadsURL.generateUniqueFilePath(filename: name) -// } -// -// var fileAttributes: [FileAttributeKey: Any] = [:] -// if let creatorCode = self.fileInfoFork?.creator { -// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber -// } -// if let typeCode = self.fileInfoFork?.type { -// fileAttributes[.hfsTypeCode] = typeCode as NSNumber -// } -// if let createdDate = self.fileInfoFork?.createdDate { -// fileAttributes[.creationDate] = createdDate as NSDate -// } -// if let modifiedDate = self.fileInfoFork?.modifiedDate { -// fileAttributes[.modificationDate] = modifiedDate as NSDate -// } -// if let comment = self.fileInfoFork?.comment { -// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { -// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ -// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData -// ] -// } -// } -// -// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { -// if let h = FileHandle(forWritingAtPath: filePath) { -// self.currentFilePath = filePath -// self.fileHandle = h -// -// // Only set file progress on first file -// if self.currentItemNumber == 1 && self.folderPath != nil { -// self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() -// self.fileProgress.fileOperationKind = .downloading -// self.fileProgress.publish() -// } -// -// return true -// } -// } -// -// return false -// } -//} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index f065233..10934e7 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -1,21 +1,13 @@ -// -// HotlineFileUploadClientNew.swift -// Hotline -// -// Modern async/await file upload client using NetSocketNew -// - import Foundation import Network -/// Modern async/await file upload client for Hotline protocol @MainActor public class HotlineFileUploadClientNew { // MARK: - Configuration public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public var publishProgress: Bool = true + public init() {} } @@ -186,11 +178,9 @@ public class HotlineFileUploadClientNew { progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) // Configure progress for Finder if enabled - if config.publishProgress { - self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - } + self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() // Send DATA fork if present if dataForkSize > 0 { diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 9dcb7c9..4ad35e7 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -1,10 +1,3 @@ -// -// HotlineFolderDownloadClientNew.swift -// Hotline -// -// Modern async/await folder download client using NetSocketNew -// - import Foundation import Network @@ -15,24 +8,14 @@ public struct HotlineFolderItemProgress: Sendable { public let totalItems: Int } -/// Modern async/await folder download client for Hotline protocol @MainActor public class HotlineFolderDownloadClientNew { - // MARK: - Configuration - - public struct Configuration: Sendable { - public var publishProgress: Bool = true - public init() {} - } - // MARK: - Properties private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 - private let config: Configuration - private let transferTotal: Int private let folderItemCount: Int private var transferSize: Int = 0 @@ -48,13 +31,11 @@ public class HotlineFolderDownloadClientNew { port: UInt16, reference: UInt32, size: UInt32, - itemCount: Int, - configuration: Configuration = .init() + itemCount: Int ) { self.serverAddress = address self.serverPort = port self.referenceNumber = reference - self.config = configuration self.transferTotal = Int(size) self.folderItemCount = itemCount @@ -141,18 +122,11 @@ public class HotlineFolderDownloadClientNew { try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) // Create and publish progress for the entire folder (shows in Finder) - if config.publishProgress { - let progress = Progress(totalUnitCount: Int64(self.transferTotal)) - progress.fileURL = destinationURL - progress.fileOperationKind = .downloading - progress.publish() - self.folderProgress = progress - } - defer { - // Unpublish progress when folder download completes - self.folderProgress?.unpublish() - self.folderProgress = nil - } + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress // Send initial magic header print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 0b78f33..2efced2 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -15,14 +15,12 @@ private struct FolderItem { let isFolder: Bool } -/// Modern async/await folder upload client for Hotline protocol @MainActor public class HotlineFolderUploadClientNew { // MARK: - Configuration public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public var publishProgress: Bool = true public init() {} } @@ -163,7 +161,6 @@ public class HotlineFolderUploadClientNew { }) progressHandler?(.connected) -// progressHandler?(.transfer(size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) var completedItemCount = 0 var totalBytesTransferred = 0 @@ -171,7 +168,7 @@ public class HotlineFolderUploadClientNew { var stage: UploadStage = .waitingForNextFile var currentItem: FolderItem? - // State machine loop - matches C++ client's goto-based state machine + // State machine loop while stage != .done { switch stage { @@ -330,7 +327,6 @@ public class HotlineFolderUploadClientNew { } } - // Following C++ client behavior: Build hierarchy with root folder name prepended to all paths // Start from root folder with root name as first path component try walkFolder(at: folderURL, relativePath: [rootFolderName]) totalItems = folderItems.count @@ -339,8 +335,6 @@ public class HotlineFolderUploadClientNew { } private func encodeItemHeader(item: FolderItem) -> Data { - // Following C++ client behavior: Skip the first path component (root folder name) - // The C++ client does: startPtr += 2; startPtr += *startPtr + 1; pathCount--; let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents let strippedPathCount = strippedPath.count @@ -428,22 +422,17 @@ public class HotlineFolderUploadClientNew { bytesUploaded += HotlineFileForkHeader.DataSize // Send INFO fork data - print("HotlineFolderUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") try await socket.write(infoForkData) bytesUploaded += infoForkData.count // Create per-file progress for Finder - var fileProgress: Progress? - if config.publishProgress { - let progress = Progress(totalUnitCount: Int64(totalFileSize)) - progress.fileURL = fileURL.resolvingSymlinksInPath() - progress.fileOperationKind = Progress.FileOperationKind.uploading - progress.publish() - fileProgress = progress - } + let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) + fileProgress.fileURL = fileURL.resolvingSymlinksInPath() + fileProgress.fileOperationKind = Progress.FileOperationKind.uploading + fileProgress.publish() defer { - fileProgress?.unpublish() + fileProgress.unpublish() } // Send DATA fork if present @@ -459,7 +448,7 @@ public class HotlineFolderUploadClientNew { let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) for try await p in updates { // Update per-file Finder progress - fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) // Calculate overall folder progress let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent @@ -503,7 +492,7 @@ public class HotlineFolderUploadClientNew { let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) for try await p in updates { // Update per-file Finder progress - fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) // Calculate overall folder progress let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent @@ -532,7 +521,6 @@ public class HotlineFolderUploadClientNew { bytesUploaded += Int(resourceForkSize) } - print("HotlineFolderUploadClientNew[\(referenceNumber)]: File upload complete, \(bytesUploaded) bytes sent") return bytesUploaded } } diff --git a/Hotline/Library/NetSocket.swift b/Hotline/Library/NetSocket.swift deleted file mode 100644 index b1f1742..0000000 --- a/Hotline/Library/NetSocket.swift +++ /dev/null @@ -1,274 +0,0 @@ - -// NetSocket.swift -// A simple delegate based buffered read/write TCP socket. -// Created by Dustin Mierau - -import Foundation - -protocol NetSocketDelegate: AnyObject { - func netsocketConnected(socket: NetSocket) - func netsocketDisconnected(socket: NetSocket, error: Error?) - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) - func netsocketSent(socket: NetSocket, count: Int) -} - -extension NetSocketDelegate { - func netsocketConnected(socket: NetSocket) {} - func netsocketDisconnected(socket: NetSocket, error: Error?) {} - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {} - func netsocketSent(socket: NetSocket, count: Int) {} -} - -enum NetSocketStatus { - case disconnected - case connecting - case connected -} - -final class NetSocket: NSObject, StreamDelegate { - weak var delegate: NetSocketDelegate? = nil - - private var output: OutputStream? = nil - private var input: InputStream? = nil - - private var outputBuffer: [UInt8] = [] - private var inputBuffer: [UInt8] = [] - - private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024) - - public func peek() -> [UInt8] { self.inputBuffer } - public var available: Int { self.inputBuffer.count } - - private var status: NetSocketStatus = .disconnected - - @MainActor public func has(_ length: Int) -> Bool { - return (self.available >= length) - } - - override init() {} - - @MainActor public func connect(host: String, port: Int) { - self.close() - - var outputStream: OutputStream? = nil - var inputStream: InputStream? = nil - - self.status = .connecting - - Stream.getStreamsToHost(withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream) - - self.input = inputStream - self.output = outputStream - - inputStream?.delegate = self - outputStream?.delegate = self - - inputStream?.schedule(in: .current, forMode: .default) - outputStream?.schedule(in: .current, forMode: .default) - - inputStream?.open() - outputStream?.open() - } - - @MainActor public func close(_ err: Error? = nil) { - print("NetSocket: Closed") - - let disconnected = (self.status != .disconnected) - - self.status = .disconnected - - self.input?.delegate = nil - self.output?.delegate = nil - self.input?.close() - self.output?.close() - self.input?.remove(from: .current, forMode: .default) - self.output?.remove(from: .current, forMode: .default) - self.input = nil - self.output = nil - self.inputBuffer = [] - self.outputBuffer = [] - - if disconnected { - self.delegate?.netsocketDisconnected(socket: self, error: err) - } - } - - @MainActor public func write(_ data: Data) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func write(_ data: [UInt8]) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func read(count: Int) -> [UInt8] { - guard self.inputBuffer.count > 0, count > 0 else { - return [] - } - - let amountToRead = min(count, self.inputBuffer.count) - let dataRead: [UInt8] = Array(self.inputBuffer[0..<amountToRead]) - self.inputBuffer.removeFirst(amountToRead) - - return dataRead - } - - @MainActor public func read(count: Int) -> Data { - guard self.inputBuffer.count > 0, count > 0 else { - return Data() - } - - let amountToRead = min(count, self.inputBuffer.count) - - let dataRead: Data = Data(self.inputBuffer[0..<amountToRead]) - self.inputBuffer.removeFirst(amountToRead) - - return dataRead - } - - @MainActor public func readAll() -> [UInt8] { - guard self.inputBuffer.count > 0 else { - return [] - } - - let dataRead: [UInt8] = Array(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor public func readAll() -> Data { - guard self.inputBuffer.count > 0 else { - return Data() - } - - let dataRead: Data = Data(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor private func writeBufferToStream() { - guard let output = self.output, self.outputBuffer.count > 0 else { - return - } - - let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count) - print("NetSocket => \(bytesWritten) bytes") - if bytesWritten > 0 { - self.outputBuffer.removeFirst(bytesWritten) - self.delegate?.netsocketSent(socket: self, count: bytesWritten) - } - else if bytesWritten == -1 { - self.close(output.streamError) - } - } - - @MainActor private func readStreamToBuffer() { - guard let input = self.input else { - return - } - - let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024) - print("NetSocket <= \(bytesRead) bytes") - if bytesRead > 0 { - self.inputBuffer.append(contentsOf: self.readBuffer[0..<bytesRead]) - self.delegate?.netsocketReceived(socket: self, bytes: self.inputBuffer) - } - else if bytesRead == -1 { - self.close(input.streamError) - } - } - - // MARK: - - - @MainActor func stream(_ aStream: Stream, handle eventCode: Stream.Event) { - guard let input = self.input, let output = self.output else { - return - } - - switch eventCode { - case .openCompleted: - if aStream == input { - self.setupStreamOptions() - } - if input.streamStatus == .open && output.streamStatus == .open { - if self.status == .connecting { - print("NetSocket: Connected") - self.status = .connected - self.delegate?.netsocketConnected(socket: self) - } - } - break - case .hasBytesAvailable: - print("NetSocket: Data available") - self.readStreamToBuffer() - case .hasSpaceAvailable: - print("NetSocket: Space available") - self.writeBufferToStream() - case .endEncountered: - print("NetSocket: End encounterd") - self.close() - case .errorOccurred: - print("NetSocket: Error encounterd", input.streamError.debugDescription, output.streamError.debugDescription) - let err = input.streamError ?? output.streamError - self.close(err) - default: - break - } - } - - // MARK: - - - private func setupStreamOptions() { - if let input = self.input { - let socketData: Data = CFReadStreamCopyProperty(input as CFReadStream, CFStreamPropertyKey.socketNativeHandle) as! Data; - var socketHandle: CFSocketNativeHandle = 0; - (socketData as NSData).getBytes(&socketHandle, length: MemoryLayout.size(ofValue: socketHandle)); - - var value: Int = 0; - let size = UInt32(MemoryLayout.size(ofValue: value)); - - value = 1; - if setsockopt(socketHandle, IPPROTO_TCP, TCP_NODELAY, &value, size) != 0 { - print("NetSocket: failed to set TCP_NODELAY"); - } - // Enable keepalive - value = 1; - if setsockopt(socketHandle, SOL_SOCKET, SO_KEEPALIVE, &value, size) != 0 { - print("NetSocket: failed to set SO_KEEPALIVE"); - } - // Number of keepalives before close (including first keepalive packet) - value = 5 - if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPCNT, &value, size) != 0 { - print("NetSocket: failed to set TCP_KEEPCNT"); - } - // Idle time used when SO_KEEPALIVE is enabled. Sets how long connection must be idle before keepalive is sent. - value = 30 - if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPALIVE, &value, size) != 0 { - print("NetSocket: failed to set TCP_KEEPALIVE") - } - // Idle time used when TCP_KEEPINTVL is enabled. Sets how long to wait between probes. - value = 10 - if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPINTVL, &value, size) != 0 { - print("NetSocket: failed to set TCP_KEEPINTVL") - } - } - } -} 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/State/HotlineState.swift index d5ab438..d5ab438 100644 --- a/Hotline/Models/HotlineState.swift +++ b/Hotline/State/HotlineState.swift diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index 8788d66..710ff20 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -8,91 +8,82 @@ struct MessageBoardView: View { var body: some View { NavigationStack { - if model.access?.contains(.canReadMessageBoard) != false { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(model.messageBoard, id: \.self) { msg in - Text(LocalizedStringKey(msg)) - .tint(Color("Link Color")) - .lineLimit(100) - .lineSpacing(4) - .padding() - .textSelection(.enabled) - Divider() - } - } - Spacer() + if self.model.access?.contains(.canReadMessageBoard) != false { + if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { + self.emptyBoardView } - .task { - if !model.messageBoardLoaded { - let _ = try? await model.getMessageBoard() - } + else { + self.messageBoardView } - .overlay { - if !model.messageBoardLoaded { - VStack { - ProgressView() - .controlSize(.large) - } - .frame(maxWidth: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) } else { - ZStack(alignment: .center) { - Text("No Message Board") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) + self.disabledBoardView } } .sheet(isPresented: $composerDisplayed) { MessageBoardEditorView() .frame(maxWidth: .infinity, maxHeight: .infinity) .frame(idealWidth: 450, idealHeight: 350) -// RichTextEditor(text: $composerText) -// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) -// .richEditorAutomaticDashSubstitution(false) -// .richEditorAutomaticQuoteSubstitution(false) -// .richEditorAutomaticSpellingCorrection(false) -// .background(Color(nsColor: .textBackgroundColor)) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .frame(idealWidth: 450, idealHeight: 350) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// composerDisplayed.toggle() -// } -// } -// -// ToolbarItem(placement: .primaryAction) { -// Button("Post") { -// composerDisplayed.toggle() -// let text = composerText -// composerText = "" -// model.postToMessageBoard(text: text) -// Task { -// await model.getMessageBoard() -// } -// } -// } -// } } .toolbar { ToolbarItem(placement:.primaryAction) { Button { - composerDisplayed.toggle() + self.composerDisplayed.toggle() } label: { Image(systemName: "square.and.pencil") } - .disabled(model.access?.contains(.canPostMessageBoard) == false) + .disabled(self.model.access?.contains(.canPostMessageBoard) == false) .help("Post to Message Board") } } + .task { + if !self.model.messageBoardLoaded { + let _ = try? await self.model.getMessageBoard() + } + } + } + + private var disabledBoardView: some View { + ContentUnavailableView { + Label("Message Board Disabled", systemImage: "quote.bubble") + } description: { + Text("This server has turned off the message board") + } + } + + private var emptyBoardView: some View { + ContentUnavailableView { + Label("No Posts", systemImage: "quote.bubble") + } description: { + Text("Message board posts will appear here") + } + } + + private var messageBoardView: some View { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(self.model.messageBoard, id: \.self) { msg in + Text(LocalizedStringKey(msg)) + .tint(Color("Link Color")) + .lineLimit(100) + .lineSpacing(4) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .overlay { + if !self.model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) } } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index b499fe6..d4ba5c8 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -16,142 +16,6 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false - - private var isShowingSearchResults: Bool { - switch model.fileSearchStatus { - case .idle: - return !model.fileSearchResults.isEmpty - case .cancelled(_): - return !model.fileSearchResults.isEmpty - default: - return true - } - } - - private var displayedFiles: [FileInfo] { - isShowingSearchResults ? model.fileSearchResults : model.files - } - - private var searchStatusMessage: String? { - switch model.fileSearchStatus { - case .searching(let processed, _): - let scanned = processed == 1 ? "folder" : "folders" - return "Searched \(processed) \(scanned)..." - case .completed(let processed): - let count = model.fileSearchResults.count - let folderWord = processed == 1 ? "folder" : "folders" - if count == 0 { - return "No files found in \(processed) \(folderWord)" - } - return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(_): - if model.fileSearchResults.isEmpty { - return nil - } - return "Search cancelled" - case .failed(let message): - return "Search failed: \(message)" - case .idle: - return nil - } - } - - private var searchStatusPath: String? { - guard let path = model.fileSearchCurrentPath else { - return nil - } - if path.isEmpty { - return "/" - } - return path.joined(separator: "/") - } - - private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { - switch previewInfo.previewType { - case .image: - openWindow(id: "preview-quicklook", value: previewInfo) - case .text: - openWindow(id: "preview-quicklook", value: previewInfo) - case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) - return - } - } - - @MainActor private func getFileInfo(_ file: FileInfo) { - Task { - if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } - } - } - } - - @MainActor private func downloadFile(_ file: FileInfo) { - if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) - } - else { - model.downloadFileNew(file.name, path: file.path) - } - } - - @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: path) - } - } - } - - @MainActor private func upload(file fileURL: URL, to path: [String]) { - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { - return - } - - if fileIsDirectory.boolValue { - self.model.uploadFolder(url: fileURL, path: path, complete: { info in - Task { - // Refresh file listing to display newly uploaded file. - try? await model.getFileList(path: path) - } - }) - } - else { - self.model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - try? await model.getFileList(path: path) - } - } - } - } - - @MainActor private func previewFile(_ file: FileInfo) { - guard file.isPreviewable else { - return - } - - model.previewFile(file.name, path: file.path) { info in - if let info = info { - openPreviewWindow(info) - } - } - } - - private func deleteFile(_ file: FileInfo) async { - var parentPath: [String] = [] - if file.path.count > 1 { - parentPath = Array(file.path[0..<file.path.count-1]) - } - - if (try? await model.deleteFile(file.name, path: file.path)) == true { - let _ = try? await model.getFileList(path: parentPath) - } - } var body: some View { NavigationStack { @@ -459,6 +323,142 @@ struct FilesView: View { } } } + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, _): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(_): + if model.fileSearchResults.isEmpty { + return nil + } + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } + + private var searchStatusPath: String? { + guard let path = model.fileSearchCurrentPath else { + return nil + } + if path.isEmpty { + return "/" + } + return path.joined(separator: "/") + } + + private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { + switch previewInfo.previewType { + case .image: + openWindow(id: "preview-quicklook", value: previewInfo) + case .text: + openWindow(id: "preview-quicklook", value: previewInfo) + case .unknown: + openWindow(id: "preview-quicklook", value: previewInfo) + return + } + } + + @MainActor private func getFileInfo(_ file: FileInfo) { + Task { + if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { + Task { @MainActor in + self.fileDetails = fileInfo + } + } + } + } + + @MainActor private func downloadFile(_ file: FileInfo) { + if file.isFolder { + model.downloadFolderNew(file.name, path: file.path) + } + else { + model.downloadFileNew(file.name, path: file.path) + } + } + + @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { + model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = try? await model.getFileList(path: path) + } + } + } + + @MainActor private func upload(file fileURL: URL, to path: [String]) { + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { + return + } + + if fileIsDirectory.boolValue { + self.model.uploadFolder(url: fileURL, path: path, complete: { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } + }) + } + else { + self.model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } + } + } + } + + @MainActor private func previewFile(_ file: FileInfo) { + guard file.isPreviewable else { + return + } + + model.previewFile(file.name, path: file.path) { info in + if let info = info { + openPreviewWindow(info) + } + } + } + + private func deleteFile(_ file: FileInfo) async { + var parentPath: [String] = [] + if file.path.count > 1 { + parentPath = Array(file.path[0..<file.path.count-1]) + } + + if (try? await model.deleteFile(file.name, path: file.path)) == true { + let _ = try? await model.getFileList(path: parentPath) + } + } } #Preview { diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index dfc5ab2..a07a441 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -18,16 +18,7 @@ struct NewsView: View { var body: some View { Group { if model.serverVersion < 151 { - VStack { - Text("No News") - .bold() - .foregroundStyle(.secondary) - .font(.title3) - Text("This server has news turned off.") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - .padding() + disabledNewsView } else { NavigationStack { @@ -37,14 +28,7 @@ struct NewsView: View { loadingIndicator } else if model.news.isEmpty { - ZStack(alignment: .center) { - Text("No News") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) + emptyNewsView } else { newsBrowser @@ -142,7 +126,23 @@ struct NewsView: View { } } - var newsBrowser: some View { + private var disabledNewsView: some View { + ContentUnavailableView { + Label("No News", systemImage: "newspaper") + } description: { + Text("This server has turned off newsgroups") + } + } + + private var emptyNewsView: some View { + ContentUnavailableView { + Label("No News", systemImage: "newspaper") + } description: { + Text("This server has no newsgroups") + } + } + + private var newsBrowser: some View { List(model.news, id: \.self, selection: $selection) { newsItem in NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) } @@ -215,7 +215,7 @@ struct NewsView: View { } } - var loadingIndicator: some View { + private var loadingIndicator: some View { VStack { HStack { ProgressView { @@ -227,7 +227,7 @@ struct NewsView: View { .frame(maxWidth: .infinity) } - var articleViewer: some View { + private var articleViewer: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { if let selection = selection, selection.type == .article { |