aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-07 10:39:48 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-07 10:39:48 -0800
commit39f51fd902bb34272c78ffdfb872c22095382f70 (patch)
treefee51effb6431aeb5464931052ecdd0df32c6792
parent87f08cf60a5d7c1cf618463916cbac4dab88e0f8 (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.pbxproj8
-rw-r--r--Hotline/Hotline/HotlineClient.swift878
-rw-r--r--Hotline/Hotline/HotlineTransferClient.swift1834
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift18
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift38
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift28
-rw-r--r--Hotline/Library/NetSocket.swift274
-rw-r--r--Hotline/Models/Hotline.swift1876
-rw-r--r--Hotline/State/HotlineState.swift (renamed from Hotline/Models/HotlineState.swift)0
-rw-r--r--Hotline/macOS/Board/MessageBoardView.swift121
-rw-r--r--Hotline/macOS/Files/FilesView.swift272
-rw-r--r--Hotline/macOS/News/NewsView.swift42
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 {