From a4263aea6e2875fa77783685985e5c8f7991337f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 10 Nov 2025 15:30:16 -0800 Subject: More cleanup and fix for connect view showing right before login UI displays. Move connect form to ConnectView. --- Hotline.xcodeproj/project.pbxproj | 6 +- Hotline/Hotline/HotlineClientNew.swift | 13 +- Hotline/Hotline/HotlineTrackerClient.swift | 3 +- Hotline/Hotline/HotlineTransferClient.swift | 286 --------------------- .../Transfers/HotlineFileDownloadClientNew.swift | 51 +--- .../Transfers/HotlineFilePreviewClientNew.swift | 46 ++-- .../Transfers/HotlineFileUploadClientNew.swift | 96 +++---- .../Transfers/HotlineFolderDownloadClientNew.swift | 21 +- .../Transfers/HotlineFolderUploadClientNew.swift | 18 +- .../Hotline/Transfers/HotlineTransferClient.swift | 286 +++++++++++++++++++++ Hotline/Library/Extensions.swift | 4 + Hotline/Library/NetSocket/NetSocket.swift | 35 +-- Hotline/MacApp.swift | 45 ++-- Hotline/State/FilePreviewState.swift | 11 +- Hotline/State/HotlineState.swift | 22 +- Hotline/State/ServerState.swift | 2 +- Hotline/iOS/TrackerView.swift | 216 ++++++++-------- Hotline/macOS/ConnectView.swift | 147 +++++++++++ Hotline/macOS/Files/FilePreviewQuickLookView.swift | 22 +- Hotline/macOS/HotlinePanelView.swift | 2 +- Hotline/macOS/ServerView.swift | 261 +++++-------------- 21 files changed, 748 insertions(+), 845 deletions(-) delete mode 100644 Hotline/Hotline/HotlineTransferClient.swift create mode 100644 Hotline/Hotline/Transfers/HotlineTransferClient.swift create mode 100644 Hotline/macOS/ConnectView.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index f91bcf8..aa10d51 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735022B30C0BB000C56F6 /* MessageView.swift */; platformFilters = (macos, ); }; DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735042B3218D8000C56F6 /* NewsView.swift */; platformFilters = (macos, ); }; DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735062B3251B3000C56F6 /* SoundEffects.swift */; }; + DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF5BC6B2EC2727100551E4D /* ConnectView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -225,6 +226,7 @@ DAE735022B30C0BB000C56F6 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; DAE735042B3218D8000C56F6 /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = ""; }; DAE735062B3251B3000C56F6 /* SoundEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEffects.swift; sourceTree = ""; }; + DAF5BC6B2EC2727100551E4D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -245,6 +247,7 @@ DA3429B12EBA73730010784E /* Transfers */ = { isa = PBXGroup; children = ( + DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, @@ -470,7 +473,6 @@ children = ( DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, - DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, DA3429B12EBA73730010784E /* Transfers */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, DAC87F062C5010E80060FADF /* HotlineExtensions.swift */, @@ -526,6 +528,7 @@ DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, + DAF5BC6B2EC2727100551E4D /* ConnectView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, DA501BE52EBE9520001714F8 /* Trackers */, @@ -667,6 +670,7 @@ DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, + DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 303bd32..854c7ff 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -64,7 +64,7 @@ public enum HotlineClientError: Error { // MARK: - Login Info /// Information needed to log in to a Hotline server -public struct HotlineLoginInfo: Sendable { +public struct HotlineLogin: Sendable { let login: String let password: String let username: String @@ -100,7 +100,7 @@ public struct HotlineServerInfo: Sendable { /// let client = try await HotlineClientNew.connect( /// host: "server.example.com", /// port: 5500, -/// login: HotlineLoginInfo(login: "guest", password: "", username: "John", iconID: 414) +/// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) /// ) /// /// // Listen for events @@ -188,14 +188,13 @@ public actor HotlineClientNew { public static func connect( host: String, port: UInt16 = 5500, - login: HotlineLoginInfo, - tls: TLSPolicy = .disabled + login: HotlineLogin ) async throws -> HotlineClientNew { print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") // Connect socket print("HotlineClientNew.connect(): Connecting socket...") - let socket = try await NetSocket.connect(host: host, port: port, tls: tls) + let socket = try await NetSocket.connect(host: host, port: port) print("HotlineClientNew.connect(): Socket connected") // Perform handshake @@ -264,7 +263,7 @@ public actor HotlineClientNew { // MARK: - Login - private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo { + private func performLogin(_ login: HotlineLogin) async throws -> HotlineServerInfo { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .login) transaction.setFieldEncodedString(type: .userLogin, val: login.login) transaction.setFieldEncodedString(type: .userPassword, val: login.password) @@ -273,7 +272,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .versionNumber, val: 123) let reply = try await sendTransaction(transaction) - + guard reply.errorCode == 0 else { let errorText = reply.getField(type: .errorText)?.getString() throw HotlineClientError.loginFailed(errorText) diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index f72735d..72ff233 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -74,8 +74,7 @@ class HotlineTrackerClient { // Connect to tracker (plaintext, no TLS) let socket = try await NetSocket.connect( host: address, - port: UInt16(port), - tls: .disabled + port: UInt16(port) ) defer { Task { await socket.close() } } diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift deleted file mode 100644 index 596155b..0000000 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ /dev/null @@ -1,286 +0,0 @@ -import Foundation -import Network -import UniformTypeIdentifiers - -protocol HotlineTransferClient { -// var serverAddress: NWEndpoint.Host { get } -// var serverPort: NWEndpoint.Port { get } -// var referenceNumber: UInt32 { get } -// var status: HotlineTransferStatus { get set } - -// func start() - func cancel() -} - -enum HotlineTransferClientError: Error { - case failedToConnect - case failedToTransfer - case cancelled -} - -enum HotlineFileFork { - case none - case info - case data - case resource - case unsupported -} - -enum HotlineTransferStatus: Equatable { - case unconnected - case connecting - case connected - case progress(Double) - case completing - case completed - case failed(HotlineTransferClientError) -} - -enum HotlineFileForkType: UInt32 { - case none = 0 - case unsupported = 1 - case info = 0x494E464F // 'INFO' - case data = 0x44415441 // 'DATA' - case resource = 1296122706 // 'MACR' -} - -public enum HotlineFolderAction: UInt16 { - case sendFile = 1 - case resumeFile = 2 - case nextFile = 3 -} - -struct HotlineFileHeader { - static let DataSize: Int = 4 + 2 + 16 + 2 - - let format: UInt32 - let version: UInt16 - let forkCount: UInt16 - - init?(from data: Data) { - guard data.count >= HotlineFileHeader.DataSize else { - return nil - } - - self.format = data.readUInt32(at: 0)! - self.version = data.readUInt16(at: 4)! - // 16 bytes of reserved data sits here. Skip it. - self.forkCount = data.readUInt16(at: 4 + 2 + 16)! - } - - init?(file fileURL: URL) { - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return nil - } - - self.format = "FILP".fourCharCode() - self.version = 1 - - let resourceURL = fileURL.urlForResourceFork() - if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { - self.forkCount = 3 - } - else { - self.forkCount = 2 - } - } - - func data() -> Data { - Data(endian: .big) { - self.format - self.version - Data(repeating: 0, count: 16) - self.forkCount - } - } -} - -// MARK: - - -struct HotlineFileForkHeader { - static let DataSize: Int = 4 + 4 + 4 + 4 - - let forkType: UInt32 - let compressionType: UInt32 - let dataSize: UInt32 - - init(type: UInt32, dataSize: UInt32) { - self.forkType = type - self.compressionType = 0 - self.dataSize = dataSize - } - - init?(from data: Data) { - guard data.count >= HotlineFileForkHeader.DataSize else { - return nil - } - - self.forkType = data.readUInt32(at: 0)! - self.compressionType = data.readUInt32(at: 4)! - // 4 bytes of reserved data sits here. Skip it. - // self.reserved = data.readUInt32(at: 4 + 4)! - self.dataSize = data.readUInt32(at: 4 + 4 + 4)! - } - - func data() -> Data { - Data(endian: .big) { - self.forkType - self.compressionType - UInt32.zero - self.dataSize - } - } - - var isInfoFork: Bool { - return self.forkType == HotlineFileForkType.info.rawValue - } - - var isDataFork: Bool { - return self.forkType == HotlineFileForkType.data.rawValue - } - - var isResourceFork: Bool { - return self.forkType == HotlineFileForkType.resource.rawValue - } -} - -// MARK: - - -struct HotlineFileInfoFork { - static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2 - - let platform: UInt32 - let type: UInt32 - let creator: UInt32 - let flags: UInt32 - let platformFlags: UInt32 - let createdDate: Date - let modifiedDate: Date - let nameScript: UInt16 - let name: String - let comment: String? - var headerSize: Int - - init?(file fileURL: URL) { - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return nil - } - - self.platform = "AMAC".fourCharCode() - - if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) { - self.type = hfsInfo.hfsType - self.creator = hfsInfo.hfsCreator - } - else { - self.type = 0 - self.creator = 0 - } - - self.flags = 0 - self.platformFlags = 0 - - let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL) - self.createdDate = dateInfo.createdDate - self.modifiedDate = dateInfo.modifiedDate - - self.nameScript = 0 - self.name = fileURL.lastPathComponent - - let fileComment = try? FileManager.default.getFinderComment(fileURL) - self.comment = fileComment ?? "" - - self.headerSize = 0 - } - - init?(from data: Data) { - // Make sure we have at least enough data to read basic header data - guard data.count >= HotlineFileInfoFork.BaseDataSize else { - return nil - } - - if - let platform = data.readUInt32(at: 0), - let type = data.readUInt32(at: 4), - let creator = data.readUInt32(at: 4 + 4), - let flags = data.readUInt32(at: 4 + 4 + 4), - let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4), - // 32 bytes of reserved data sits here. Skip it. - let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) { - - let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now - let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now - - let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2) - if let name = n { - self.platform = platform - self.type = type - self.creator = creator - self.flags = flags - self.platformFlags = platformFlags - self.createdDate = createdDate - self.modifiedDate = modifiedDate - self.nameScript = nameScript - self.name = name - - var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl - var commentRead: String? = nil - if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 { - let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)! - var commentCorrupted = false - - // Some servers have incorrect data length for the INFO fork - // the length they send is what it should be but don't include - // the comment length in the actual data, so we end up with mismatched - // lengths. So here we test if the length we read is actually 'DA' - // or the first part of the "DATA" fork header. - // Needless to say, stuff like this makes for sad code but this ain't so bad. - if commentLength == 0x4441 { - commentCorrupted = true - } - - if !commentCorrupted { - let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl) - calculatedHeaderSize += 2 - if cl > 0 { - calculatedHeaderSize += Int(cl) - if let ct = c, cl > 0 { - commentRead = ct - } - } - } - } - - self.comment = commentRead - self.headerSize = calculatedHeaderSize - return - } - } - - return nil - } - - func data() -> Data { - let fileName = self.name.data(using: .macOSRoman)! - - let data = Data(endian: .big) { - self.platform - self.type - self.creator - self.flags - self.platformFlags - Data(repeating: 0, count: 32) - self.createdDate - self.modifiedDate - self.nameScript - UInt16(fileName.count) - fileName - if let commentData = self.comment?.data(using: .macOSRoman) { - UInt16(commentData.count) - commentData - } - } - - return data - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index 981965a..82f61d4 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -16,18 +16,14 @@ public enum HotlineTransferProgress: Sendable { case completed(url: URL?) // Download or upload complete (local url valid for downloads) } -/// Modern async/await file download client for Hotline protocol -@MainActor -public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration +@MainActor +public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 public init() {} } - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -41,8 +37,6 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { private var socket: NetSocket? private var downloadTask: Task? - // MARK: - Initialization - public init( address: String, port: UInt16, @@ -91,7 +85,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { self.downloadTask = nil } - // MARK: - Private Implementation + // MARK: - Implementation private func updateProgress(sent: Int) throws { self.transferSize = sent @@ -141,9 +135,12 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { try progressHandler?(.connecting) // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) defer { Task { await socket.close() } } + self.socket = socket // See if we've been cancelled try self.checkCancelled() @@ -257,35 +254,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { } } - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled - ) - - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connected!") - return socket - } - - private func sendMagicHeader(socket: NetSocket) async throws { - let header = Data(endian: .big) { - "HTXF".fourCharCode() - referenceNumber - UInt32.zero - UInt32.zero - } - - try await socket.write(header) - } + // MARK: - Utility private func writeResourceFork(data: Data, to url: URL) throws { var resolvedURL = url @@ -294,7 +263,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { let resourceURL = resolvedURL.urlForResourceFork() try data.write(to: resourceURL) - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") + print("HotlineFileDownloadClient[\(self.referenceNumber)]: Wrote resource fork (\(data.count) bytes)") } } diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 93a68fc..5cf5628 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -2,21 +2,17 @@ import Foundation import Network @MainActor -public class HotlineFilePreviewClientNew { - // MARK: - Properties - +public class HotlineFilePreviewClient { private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 private let fileName: String private let transferSize: UInt32 - private var downloadClient: HotlineFileDownloadClientNew? + private var downloadClient: HotlineFileDownloadClient? private var previewTask: Task? private var temporaryFileURL: URL? - // MARK: - Initialization - public init( fileName: String, address: String, @@ -31,7 +27,7 @@ public class HotlineFilePreviewClientNew { self.transferSize = size } - // MARK: - + // MARK: - API /// Download file to temporary location for preview /// - Parameter progressHandler: Optional progress callback @@ -51,7 +47,7 @@ public class HotlineFilePreviewClientNew { self.previewTask = nil return url } catch { - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Failed to preview file: \(error)") + print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") self.previewTask = nil progressHandler?(.error(error)) throw error @@ -71,7 +67,7 @@ public class HotlineFilePreviewClientNew { self.cleanupTempFile() } - // MARK: - Private Implementation + // MARK: - Implementation private func performPreview( progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? @@ -83,28 +79,22 @@ public class HotlineFilePreviewClientNew { let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) self.temporaryFileURL = tempFileURL - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") progressHandler?(.connecting) // Connect to transfer server - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled + host: self.serverAddress, + port: self.serverPort + 1 ) defer { Task { await socket.close() } } - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connected!") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") // Send magic header for raw data download - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Sending magic header") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -115,7 +105,7 @@ public class HotlineFilePreviewClientNew { progressHandler?(.connected) // Stream raw data directly to temp file with progress tracking - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Streaming \(transferSize) bytes to temp file") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file") let totalSize = Int(transferSize) @@ -139,18 +129,18 @@ public class HotlineFilePreviewClientNew { progressHandler?(.completed(url: tempFileURL)) - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Preview file ready at \(tempFileURL.path)") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") return tempFileURL } private func cleanupTempFile() { - guard let tempURL = temporaryFileURL else { return } - + guard let tempURL = self.temporaryFileURL else { return } + self.temporaryFileURL = nil + // Delete the temp file try? FileManager.default.removeItem(at: tempURL) - - temporaryFileURL = nil - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Cleaned up temp file") + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") } } diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 3bf3339..384e9bd 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -2,17 +2,12 @@ import Foundation import Network @MainActor -public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration - +public class HotlineFileUploadClient: @MainActor HotlineTransferClient { public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public init() {} } - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -27,8 +22,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { private var socket: NetSocket? private var uploadTask: Task? - // MARK: - Initialization - public init?( fileURL: URL, address: String, @@ -54,8 +47,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { self.transferTotal = Int(payloadSize) self.transferSize = 0 self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - - print("HotlineFileUploadClientNew[\(reference)]: Preparing to upload '\(fileURL.lastPathComponent)' (\(payloadSize) bytes)") } // MARK: - Public API @@ -74,7 +65,7 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { try await task.value self.uploadTask = nil } catch { - print("HotlineFileUploadClientNew[\(referenceNumber)]: Failed to upload file: \(error)") + print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") self.uploadTask = nil progressHandler?(.error(error)) throw error @@ -83,17 +74,17 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { /// Cancel the current upload public func cancel() { - uploadTask?.cancel() - uploadTask = nil + self.uploadTask?.cancel() + self.uploadTask = nil - if let socket = socket { + if let socket = self.socket { Task { await socket.close() } } } - // MARK: - Private Implementation + // MARK: - Implementation private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { self.transferSize = sent @@ -120,34 +111,39 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { } // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) defer { Task { await socket.close() } } + self.socket = socket // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } - guard let header = HotlineFileHeader(file: fileURL) else { + guard let header = HotlineFileHeader(file: self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } - guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize - - print("HotlineFileUploadClientNew[\(referenceNumber)]: File has dataFork=\(dataForkSize) bytes, resourceFork=\(resourceForkSize) bytes") + + // Configure progress for Finder if enabled + self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() // Connected progressHandler?(.connected) // Send magic header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending magic header") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -157,41 +153,34 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { var totalBytesSent = 0 + // MARK: - Info Fork // Send file header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending file header") let headerData = header.data() try await socket.write(headerData) totalBytesSent += headerData.count - // Send INFO fork header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork header") + // Send info fork header let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) try await socket.write(infoForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Send INFO fork data - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") + // Send info fork try await socket.write(infoForkData) totalBytesSent += infoForkData.count self.updateProgress(sent: totalBytesSent) progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) - // Configure progress for Finder if enabled - self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - - // Send DATA fork if present + // MARK: - Data Fork + // Send data fork (if present) if dataForkSize > 0 { - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending DATA fork header") + // Data fork header let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) try await socket.write(dataForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Stream DATA fork - print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming DATA fork (\(dataForkSize) bytes)") - let fileHandle = try FileHandle(forReadingFrom: fileURL) + // Stream data fork from disk + let fileHandle = try FileHandle(forReadingFrom: self.fileURL) defer { try? fileHandle.close() } let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) @@ -204,17 +193,17 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { totalBytesSent += Int(dataForkSize) } - // Send RESOURCE fork if present + // MARK: - Resource Fork + // Send resource fork (if present) if resourceForkSize > 0 { - let resourceURL = fileURL.urlForResourceFork() + let resourceURL = self.fileURL.urlForResourceFork() - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending RESOURCE fork header") + // Resource fork header let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) try await socket.write(resourceForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Stream RESOURCE fork - print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming RESOURCE fork (\(resourceForkSize) bytes)") + // Stream resource fork from disk let resourceHandle = try FileHandle(forReadingFrom: resourceURL) defer { try? resourceHandle.close() } @@ -231,25 +220,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { self.transferProgress.unpublish() progressHandler?(.completed(url: nil)) - print("HotlineFileUploadClientNew[\(referenceNumber)]: Upload complete!") - } - - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled - ) - - print("HotlineFileUploadClientNew[\(referenceNumber)]: Connected!") - return socket + print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") } } diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 8303d11..600b9f2 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -10,8 +10,6 @@ public struct HotlineFolderItemProgress: Sendable { @MainActor public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -129,14 +127,14 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { self.folderProgress = progress // Send initial magic header - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") + print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber UInt32.zero // data size = 0 UInt16(1) // type = 1 (folder transfer) UInt16.zero // reserved = 0 - HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) }) progressHandler?(.connected) @@ -146,7 +144,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { var totalBytesTransferred = 0 // Process each item in the folder - while completedItemCount < folderItemCount { + while completedItemCount < self.folderItemCount { // Read item header let headerLenData = try await socket.read(2) let headerLen = Int(headerLenData.readUInt16(at: 0)!) @@ -154,7 +152,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { totalBytesTransferred += 2 + headerLen - guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else { + guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { throw HotlineTransferClientError.failedToTransfer } @@ -166,7 +164,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { if !pathComponents.isEmpty { let folderURL = destinationURL.appendingPathComponents(pathComponents) try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Created folder at \(folderURL.path)") + print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)") } completedItemCount += 1 @@ -246,16 +244,11 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Helper Methods private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled + host: self.serverAddress, + port: self.serverPort + 1 ) print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 4b98b54..15636f3 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -147,7 +147,11 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { progressHandler?(.connecting) // Connect to transfer server - let socket = try await self.connect(address: self.serverAddress, port: self.serverPort) + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + self.socket = socket defer { Task { await socket.close() } } @@ -281,18 +285,6 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { progressHandler?(.completed(url: nil)) } - private func connect(address: String, port: UInt16) async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { - throw NetSocketError.invalidPort - } - - return try await NetSocket.connect( - host: .name(address, nil), - port: transferPort, - tls: .disabled - ) - } - private func buildFolderHierarchy() throws { let fm = FileManager.default folderItems = [] diff --git a/Hotline/Hotline/Transfers/HotlineTransferClient.swift b/Hotline/Hotline/Transfers/HotlineTransferClient.swift new file mode 100644 index 0000000..596155b --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineTransferClient.swift @@ -0,0 +1,286 @@ +import Foundation +import Network +import UniformTypeIdentifiers + +protocol HotlineTransferClient { +// var serverAddress: NWEndpoint.Host { get } +// var serverPort: NWEndpoint.Port { get } +// var referenceNumber: UInt32 { get } +// var status: HotlineTransferStatus { get set } + +// func start() + func cancel() +} + +enum HotlineTransferClientError: Error { + case failedToConnect + case failedToTransfer + case cancelled +} + +enum HotlineFileFork { + case none + case info + case data + case resource + case unsupported +} + +enum HotlineTransferStatus: Equatable { + case unconnected + case connecting + case connected + case progress(Double) + case completing + case completed + case failed(HotlineTransferClientError) +} + +enum HotlineFileForkType: UInt32 { + case none = 0 + case unsupported = 1 + case info = 0x494E464F // 'INFO' + case data = 0x44415441 // 'DATA' + case resource = 1296122706 // 'MACR' +} + +public enum HotlineFolderAction: UInt16 { + case sendFile = 1 + case resumeFile = 2 + case nextFile = 3 +} + +struct HotlineFileHeader { + static let DataSize: Int = 4 + 2 + 16 + 2 + + let format: UInt32 + let version: UInt16 + let forkCount: UInt16 + + init?(from data: Data) { + guard data.count >= HotlineFileHeader.DataSize else { + return nil + } + + self.format = data.readUInt32(at: 0)! + self.version = data.readUInt16(at: 4)! + // 16 bytes of reserved data sits here. Skip it. + self.forkCount = data.readUInt16(at: 4 + 2 + 16)! + } + + init?(file fileURL: URL) { + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.format = "FILP".fourCharCode() + self.version = 1 + + let resourceURL = fileURL.urlForResourceFork() + if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { + self.forkCount = 3 + } + else { + self.forkCount = 2 + } + } + + func data() -> Data { + Data(endian: .big) { + self.format + self.version + Data(repeating: 0, count: 16) + self.forkCount + } + } +} + +// MARK: - + +struct HotlineFileForkHeader { + static let DataSize: Int = 4 + 4 + 4 + 4 + + let forkType: UInt32 + let compressionType: UInt32 + let dataSize: UInt32 + + init(type: UInt32, dataSize: UInt32) { + self.forkType = type + self.compressionType = 0 + self.dataSize = dataSize + } + + init?(from data: Data) { + guard data.count >= HotlineFileForkHeader.DataSize else { + return nil + } + + self.forkType = data.readUInt32(at: 0)! + self.compressionType = data.readUInt32(at: 4)! + // 4 bytes of reserved data sits here. Skip it. + // self.reserved = data.readUInt32(at: 4 + 4)! + self.dataSize = data.readUInt32(at: 4 + 4 + 4)! + } + + func data() -> Data { + Data(endian: .big) { + self.forkType + self.compressionType + UInt32.zero + self.dataSize + } + } + + var isInfoFork: Bool { + return self.forkType == HotlineFileForkType.info.rawValue + } + + var isDataFork: Bool { + return self.forkType == HotlineFileForkType.data.rawValue + } + + var isResourceFork: Bool { + return self.forkType == HotlineFileForkType.resource.rawValue + } +} + +// MARK: - + +struct HotlineFileInfoFork { + static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2 + + let platform: UInt32 + let type: UInt32 + let creator: UInt32 + let flags: UInt32 + let platformFlags: UInt32 + let createdDate: Date + let modifiedDate: Date + let nameScript: UInt16 + let name: String + let comment: String? + var headerSize: Int + + init?(file fileURL: URL) { + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.platform = "AMAC".fourCharCode() + + if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) { + self.type = hfsInfo.hfsType + self.creator = hfsInfo.hfsCreator + } + else { + self.type = 0 + self.creator = 0 + } + + self.flags = 0 + self.platformFlags = 0 + + let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL) + self.createdDate = dateInfo.createdDate + self.modifiedDate = dateInfo.modifiedDate + + self.nameScript = 0 + self.name = fileURL.lastPathComponent + + let fileComment = try? FileManager.default.getFinderComment(fileURL) + self.comment = fileComment ?? "" + + self.headerSize = 0 + } + + init?(from data: Data) { + // Make sure we have at least enough data to read basic header data + guard data.count >= HotlineFileInfoFork.BaseDataSize else { + return nil + } + + if + let platform = data.readUInt32(at: 0), + let type = data.readUInt32(at: 4), + let creator = data.readUInt32(at: 4 + 4), + let flags = data.readUInt32(at: 4 + 4 + 4), + let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4), + // 32 bytes of reserved data sits here. Skip it. + let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) { + + let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now + let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now + + let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2) + if let name = n { + self.platform = platform + self.type = type + self.creator = creator + self.flags = flags + self.platformFlags = platformFlags + self.createdDate = createdDate + self.modifiedDate = modifiedDate + self.nameScript = nameScript + self.name = name + + var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl + var commentRead: String? = nil + if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 { + let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)! + var commentCorrupted = false + + // Some servers have incorrect data length for the INFO fork + // the length they send is what it should be but don't include + // the comment length in the actual data, so we end up with mismatched + // lengths. So here we test if the length we read is actually 'DA' + // or the first part of the "DATA" fork header. + // Needless to say, stuff like this makes for sad code but this ain't so bad. + if commentLength == 0x4441 { + commentCorrupted = true + } + + if !commentCorrupted { + let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl) + calculatedHeaderSize += 2 + if cl > 0 { + calculatedHeaderSize += Int(cl) + if let ct = c, cl > 0 { + commentRead = ct + } + } + } + } + + self.comment = commentRead + self.headerSize = calculatedHeaderSize + return + } + } + + return nil + } + + func data() -> Data { + let fileName = self.name.data(using: .macOSRoman)! + + let data = Data(endian: .big) { + self.platform + self.type + self.creator + self.flags + self.platformFlags + Data(repeating: 0, count: 32) + self.createdDate + self.modifiedDate + self.nameScript + UInt16(fileName.count) + fileName + if let commentData = self.comment?.data(using: .macOSRoman) { + UInt16(commentData.count) + commentData + } + } + + return data + } +} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index b6a4b68..bb28370 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -121,6 +121,10 @@ extension UTType { extension String { + var isBlank: Bool { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + func markdownToAttributedString() -> AttributedString { let markdownText = self.convertingLinksToMarkdown() let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift index e66ef3f..ffccf5c 100644 --- a/Hotline/Library/NetSocket/NetSocket.swift +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -87,7 +87,7 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { } } -/// An async/await TCP socket with automatic buffering and framing support +/// An async TCP socket with automatic buffering /// /// NetSocket provides: /// - Async connection management @@ -99,7 +99,7 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { /// ```swift /// let socket = try await NetSocket.connect(host: "example.com", port: 80) /// try await socket.write("Hello\n".data(using: .utf8)!) -/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// let response = try await socket.read(until: .lineFeed) /// ``` public actor NetSocket { /// Configuration options for the socket @@ -150,18 +150,11 @@ public actor NetSocket { /// - Parameters: /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) /// - port: Network framework port - /// - tls: TLS policy (default: enabled with default settings) /// - config: Socket configuration (default: standard settings) + /// - parameters: NWParameters (default: .tcp) /// - Returns: A connected and ready `NetSocket` /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { - let parameters = NWParameters.tcp - if tls.enabled { - let tlsOptions = NWProtocolTLS.Options() - tls.configure?(tlsOptions) - parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) - } - + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, config: Config = .init(), parameters: NWParameters = .tcp) async throws -> NetSocket { let conn = NWConnection(host: host, port: port, using: parameters) let socket = NetSocket(connection: conn, config: config) try await socket.start() @@ -169,28 +162,12 @@ public actor NetSocket { } /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { + public static func connect(host: String, port: UInt16, config: Config = .init()) async throws -> NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } - // Parse the host string to create the appropriate NWEndpoint.Host - let nwHost: NWEndpoint.Host - - // Try parsing as IPv6 without zone - if let ipv6Addr = IPv6Address(host) { - nwHost = .ipv6(ipv6Addr) - } - // Try parsing as IPv4 - else if let ipv4Addr = IPv4Address(host) { - nwHost = .ipv4(ipv4Addr) - } - // Fall back to treating as hostname - else { - nwHost = .name(host, nil) - } - - return try await self.connect(host: nwHost, port: nwPort, tls: tls, config: config) + return try await self.connect(host: NWEndpoint.Host(host), port: nwPort, config: config) } // MARK: Close diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 9052c0b..9ac54c1 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -109,7 +109,7 @@ struct Application: App { .onChange(of: AppLaunchState.shared.launchState) { if AppLaunchState.shared.launchState == .launched { if Prefs.shared.showBannerToolbar { - showBannerWindow() + self.showBannerWindow() } } } @@ -196,13 +196,13 @@ struct Application: App { .commands { CommandGroup(replacing: .newItem) { Button("Connect to Server...") { - openWindow(id: "server") + self.openWindow(id: "server") } .keyboardShortcut(.init("K"), modifiers: .command) } CommandGroup(before: .singleWindowList) { Button("Toolbar") { - toggleBannerWindow() + self.toggleBannerWindow() } .keyboardShortcut(.init("\\"), modifiers: [.shift, .command]) } @@ -210,18 +210,18 @@ struct Application: App { Divider() Button("Request Feature...") { if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") { - openURL(url) + self.openURL(url) } } Button("Report Bug...") { if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") { - openURL(url) + self.openURL(url) } } Divider() Button("Open Latest Release Page...") { if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { - openURL(url) + self.openURL(url) } } } @@ -230,7 +230,7 @@ struct Application: App { guard let selection else { return } - connect(to: selection) + self.connect(to: selection) } .disabled(selection == nil || selection?.server == nil) .keyboardShortcut(.downArrow, modifiers: .command) @@ -242,38 +242,47 @@ struct Application: App { } } .disabled(activeHotline?.status == .disconnected) + Divider() + Button("Broadcast Message...") { // TODO: Implement broadcast message when user is allowed. } .disabled(true) .keyboardShortcut(.init("B"), modifiers: .command) + Divider() - Button("Show Chat") { + + Button("Chat") { activeServerState?.selection = .chat } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("1"), modifiers: .command) - Button("Show Message Board") { + Button("Board") { activeServerState?.selection = .board } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("2"), modifiers: .command) - Button("Show News") { + Button("News") { activeServerState?.selection = .news } .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) .keyboardShortcut(.init("3"), modifiers: .command) - Button("Show Files") { + Button("Files") { activeServerState?.selection = .files } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("4"), modifiers: .command) - Button("Show Accounts") { - activeServerState?.selection = .accounts + + if activeHotline?.access?.contains(.canOpenUsers) == true { + Divider() + + Button("Accounts") { + activeServerState?.selection = .accounts + } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) + .keyboardShortcut(.init("5"), modifiers: .command) } - .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) } } @@ -287,8 +296,8 @@ struct Application: App { TransfersView() .frame(minWidth: 500, minHeight: 200) } - .defaultSize(width: 600, height: 400) - .defaultPosition(.center) + .defaultSize(width: 500, height: 400) + .defaultPosition(.topTrailing) .keyboardShortcut(.init("T"), modifiers: [.shift, .command]) // MARK: Image Preview Window @@ -328,7 +337,7 @@ struct Application: App { func connect(to item: TrackerSelection) { if let server = item.server { - openWindow(id: "server", value: server) + self.openWindow(id: "server", value: server) } } diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift index 97bd907..27f8199 100644 --- a/Hotline/State/FilePreviewState.swift +++ b/Hotline/State/FilePreviewState.swift @@ -1,10 +1,3 @@ -// -// FilePreviewState.swift -// Hotline -// -// Modern file preview state using HotlineFilePreviewClientNew -// - import SwiftUI import UniformTypeIdentifiers @@ -24,7 +17,7 @@ final class FilePreviewState { let info: PreviewFileInfo - private var previewClient: HotlineFilePreviewClientNew? + private var previewClient: HotlineFilePreviewClient? private var previewTask: Task? var state: LoadState = .unloaded @@ -67,7 +60,7 @@ final class FilePreviewState { let task = Task { @MainActor in do { - let client = HotlineFilePreviewClientNew( + let client = HotlineFilePreviewClient( fileName: info.name, address: info.address, port: UInt16(info.port), diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 14fb9aa..e80571a 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -8,6 +8,10 @@ enum HotlineConnectionStatus: Equatable { case connected case loggedIn case failed(String) + + var isLoggingIn: Bool { + self == .connecting || self == .connected + } var isConnected: Bool { return self == .connected || self == .loggedIn @@ -254,7 +258,7 @@ class HotlineState: Equatable { @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? - + // MARK: - Initialization init() { @@ -262,8 +266,10 @@ class HotlineState: Equatable { forName: ChatStore.historyClearedNotification, object: nil, queue: .main - ) { @MainActor [weak self] _ in - self?.handleChatHistoryCleared() + ) { _ in + Task { @MainActor [weak self] in + self?.handleChatHistoryCleared() + } } } @@ -295,7 +301,7 @@ class HotlineState: Equatable { do { // Connect and login - let loginInfo = HotlineLoginInfo( + let loginInfo = HotlineLogin( login: server.login, password: server.password, username: username, @@ -360,7 +366,7 @@ class HotlineState: Equatable { await client.disconnect() self.client = nil } - self.status = .failed(error.localizedDescription) + self.status = .disconnected // .failed(error.localizedDescription) self.errorDisplayed = true self.errorMessage = error.localizedDescription throw error @@ -498,7 +504,7 @@ class HotlineState: Equatable { do { print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") - let previewClient = HotlineFilePreviewClientNew( + let previewClient = HotlineFilePreviewClient( fileName: "banner", address: address, port: UInt16(port), @@ -894,7 +900,7 @@ class HotlineState: Equatable { AppState.shared.addTransfer(transfer) // Create download client - let downloadClient = HotlineFileDownloadClientNew( + let downloadClient = HotlineFileDownloadClient( address: address, port: UInt16(port), reference: referenceNumber, @@ -1270,7 +1276,7 @@ class HotlineState: Equatable { print("HotlineState: Got upload reference: \(referenceNumber)") // Create upload client - guard let uploadClient = HotlineFileUploadClientNew( + guard let uploadClient = HotlineFileUploadClient( fileURL: fileURL, address: address, port: UInt16(port), diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 5913bf5..805672d 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -29,7 +29,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case .files: return "Files" case .accounts: - return "Accounts" + return "Admin" case .user(let userID): return String(userID) } diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift index 2a3583b..8c846e5 100644 --- a/Hotline/iOS/TrackerView.swift +++ b/Hotline/iOS/TrackerView.swift @@ -9,7 +9,7 @@ struct TrackerConnectView: View { @State private var address = "" @State private var login = "" @State private var password = "" -// @State private var connecting = false + // @State private var connecting = false func connectionStatusToProgress(status: HotlineClientStatus) -> Double { switch status { @@ -27,116 +27,116 @@ struct TrackerConnectView: View { } var body: some View { - VStack(alignment: .leading) { - if self.model.status == .disconnected { - TextField("Server Address", text: $address) - .keyboardType(.URL) - .disableAutocorrection(true) - .frame(height: 48) - .textFieldStyle(.plain) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - TextField("Login", text: $login) - .disableAutocorrection(true) - .frame(height: 48) - .textFieldStyle(.plain) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - SecureField("Password", text: $password) - .disableAutocorrection(true) - .textFieldStyle(.plain) - .frame(height: 48) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - } - else { - ProgressView(value: connectionStatusToProgress(status: model.status)) - .frame(minHeight: 10) - .accentColor(colorScheme == .dark ? .white : .black) - } - - Spacer() - - HStack { - Button { - dismiss() - server = nil - model.disconnect() - } label: { - Text("Cancel") + VStack(alignment: .leading) { + if self.model.status == .disconnected { + TextField("Server Address", text: $address) + .keyboardType(.URL) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) } - .bold() - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - .frame(maxWidth: .infinity) - .foregroundColor(colorScheme == .dark ? .white : .black) - .background( - colorScheme == .dark ? - LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) - : - LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) - ) - .overlay( - RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) - ) - .cornerRadius(10.0) - Button { - let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0, login: login, password: password) - server = s - self.model.login(server: s, username: "bolt", iconID: 128) { success in - if !success { - print("FAILED LOGIN??") - } - else { - self.model.sendUserInfo(username: "bolt", iconID: 128) - self.model.getUserList() - } + TextField("Login", text: $login) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + SecureField("Password", text: $password) + .disableAutocorrection(true) + .textFieldStyle(.plain) + .frame(height: 48) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + } + else { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .frame(minHeight: 10) + .accentColor(colorScheme == .dark ? .white : .black) + } + + Spacer() + + HStack { + Button { + dismiss() + server = nil + model.disconnect() + } label: { + Text("Cancel") + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) + Button { + let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0, login: login, password: password) + server = s + self.model.login(server: s, username: "bolt", iconID: 128) { success in + if !success { + print("FAILED LOGIN??") + } + else { + self.model.sendUserInfo(username: "bolt", iconID: 128) + self.model.getUserList() } - } label: { - Text("Connect") } - .disabled(self.model.status != .disconnected) - .bold() - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - .frame(maxWidth: .infinity) - .foregroundColor(colorScheme == .dark ? .white : .black) - .background( - colorScheme == .dark ? - LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) - : - LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) - ) - .overlay( - RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) - ) - .cornerRadius(10.0) + } label: { + Text("Connect") } - .padding() + .disabled(self.model.status != .disconnected) + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) } .padding() - .onChange(of: model.status) { - print("MODEL STATUS CHANGED") - if model.server != nil && server != nil && model.server! == server! { - if model.status == .loggedIn { - dismiss() - } -// else { -// connecting = (model.status != .disconnected) -// } + } + .padding() + .onChange(of: self.model.status) { + print("MODEL STATUS CHANGED") + if self.model.server != nil && self.server != nil && self.model.server! == server! { + if self.model.status == .loggedIn { + self.dismiss() } + // else { + // connecting = (model.status != .disconnected) + // } } - .toolbar { - ToolbarItem(placement: .principal) { - Text("Connect to Server") - .font(.headline) - } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text("Connect to Server") + .font(.headline) } -// .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground)) + } + // .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground)) .presentationBackground { Color.clear .background(Material.regular) @@ -190,12 +190,12 @@ struct TrackerView: View { } func updateServers() async { -// "hltracker.com" -// "tracker.preterhuman.net" -// "hotline.ubersoft.org" -// "tracked.nailbat.com" -// "hotline.duckdns.org" -// "tracked.agent79.org" + // "hltracker.com" + // "tracker.preterhuman.net" + // "hotline.ubersoft.org" + // "tracked.nailbat.com" + // "hotline.duckdns.org" + // "tracked.agent79.org" self.servers = await model.getServerList(tracker: "hltracker.com") } diff --git a/Hotline/macOS/ConnectView.swift b/Hotline/macOS/ConnectView.swift new file mode 100644 index 0000000..8419522 --- /dev/null +++ b/Hotline/macOS/ConnectView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +struct ConnectView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Binding var address: String + @Binding var login: String + @Binding var password: String + + var action: (() -> Void)? = nil + + @State private var bookmarkSheetPresented: Bool = false + @State private var bookmarkName: String = "" + + private enum FocusFields { + case address + case login + case password + } + + @FocusState private var focusedField: FocusFields? + + var body: some View { + VStack(alignment: .center, spacing: 0) { + Form { + HStack(alignment: .top, spacing: 10) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Connect to Server") + Text("Enter the address of a Hotline server to connect to.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + + TextField(text: self.$address) { + Text("Address") + } + .focused($focusedField, equals: .address) + + TextField(text: self.$login, prompt: Text("Optional")) { + Text("Login") + } + .focused($focusedField, equals: .login) + + SecureField(text: self.$password, prompt: Text("Optional")) { + Text("Password") + } + .focused($focusedField, equals: .password) + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Button { + if !self.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.bookmarkSheetPresented = true + } + } label: { + Image(systemName: "bookmark.fill") + } + .disabled(self.address.isEmpty) + .controlSize(.regular) + .buttonStyle(.automatic) + .help("Bookmark server") + + Spacer() + + Button("Cancel") { + self.dismiss() + } + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.cancelAction) + + Button("Connect") { + self.action?() +// self.connectToServer() + } + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 20) + } +// .onChange(of: self.address) { +// let (a, p) = Server.parseServerAddressAndPort(connectAddress) +// server.address = a +// server.port = p +// } +// .onChange(of: connectLogin) { +// server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) +// } +// .onChange(of: connectPassword) { +// server.password = connectPassword +// } + .frame(maxWidth: 380) + .padding() + .onAppear { + self.focusedField = .address + } + .sheet(isPresented: self.$bookmarkSheetPresented) { + VStack(alignment: .leading) { + Text("Save Bookmark") + .foregroundStyle(.secondary) + .padding(.bottom, 4) + TextField("Bookmark Name", text: self.$bookmarkName) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + .frame(width: 250) + .padding() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.bookmarkSheetPresented = false + self.bookmarkName = "" + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let name = String(self.bookmarkName.trimmingCharacters(in: .whitespacesAndNewlines)) + if !name.isEmpty { + self.bookmarkSheetPresented = false + self.bookmarkName = "" + + let (host, port) = Server.parseServerAddressAndPort(self.address) + let login: String? = self.login.isBlank ? nil : self.login + let password: String? = self.password.isBlank ? nil : self.password + + if !host.isEmpty { + let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) + Bookmark.add(newBookmark, context: modelContext) + } + } + } + } + } + } + } +} diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift index 0323fdd..26bd286 100644 --- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -1,10 +1,3 @@ -// -// FilePreviewQuickLookView.swift -// Hotline -// -// QuickLook-based file preview window for all supported file types -// - import SwiftUI import UniformTypeIdentifiers @@ -15,10 +8,11 @@ struct FilePreviewQuickLookView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss @Binding var info: PreviewFileInfo? @State var preview: FilePreviewState? = nil + @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -76,14 +70,14 @@ struct FilePreviewQuickLookView: View { .background(Color(nsColor: .textBackgroundColor)) .focused($focusField, equals: .window) .navigationTitle(info?.name ?? "File Preview") - .background( + .background { + if let fileURL = self.preview?.fileURL { WindowConfigurator { window in - if let fileURL = preview?.fileURL { - window.representedURL = fileURL - window.standardWindowButton(.documentIconButton)?.isHidden = false - } + window.representedURL = fileURL + window.standardWindowButton(.documentIconButton)?.isHidden = false } - ) + } + } .toolbar { if let _ = preview?.fileURL { if let info = info { diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 220ea97..a56cd44 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -124,7 +124,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Accounts") + .help("Administration") } Button { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 44274ea..757dbd8 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -47,10 +47,8 @@ struct ListItemView: View { if let i = icon { Image(i) .resizable() - // .renderingMode(.template) .scaledToFit() .frame(width: 20, height: 20) -// .padding(.leading, 2) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } @@ -74,29 +72,28 @@ extension FocusedValues { } struct ServerView: View { - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.controlActiveState) private var controlActiveState @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext + @Binding var server: Server + @State private var model: HotlineState = HotlineState() @State private var state: ServerState = ServerState(selection: .chat) @State private var agreementShown: Bool = false @State private var connectAddress: String = "" @State private var connectLogin: String = "" @State private var connectPassword: String = "" - @State private var connectNameSheetPresented: Bool = false - @State private var connectName: String = "" - - @Binding var server: Server + @State private var connectionDisplayed: Bool = false static var menuItems: [ServerMenuItem] = [ ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"), ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), + ServerMenuItem(type: .accounts, name: "Admin", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -105,50 +102,20 @@ struct ServerView: View { ServerMenuItem(type: .files, name: "Files", image: "Section Files"), ] - enum FocusFields { - case address - case login - case password - } - - @FocusState private var focusedField: FocusFields? - var body: some View { Group { - if model.status == .disconnected { + if self.model.status == .disconnected { VStack(alignment: .center) { Spacer() - self.connectForm +// if self.connectionDisplayed { + self.connectForm +// } Spacer() } .navigationTitle("Connect to Server") - .onAppear { - self.focusedField = .address - } - } - else if case .failed(let error) = model.status { - VStack { - Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 18) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - .padding(.trailing, 4) - - Text("Connection Failed") - .font(.headline) - Text(error) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: 300) - .padding() - .navigationTitle("Connection Failed") +// .animation(.default.delay(0.25), value: self.connectionDisplayed) } - else if model.status != .loggedIn { + else if self.model.status.isLoggingIn { HStack { Image("Hotline") .resizable() @@ -156,38 +123,38 @@ struct ServerView: View { .scaledToFit() .foregroundColor(Color(hex: 0xE10000)) .frame(width: 18) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) - ProgressView(value: connectionStatusToProgress(status: model.status)) { - Text(connectionStatusToLabel(status: model.status)) + ProgressView(value: connectionStatusToProgress(status: self.model.status)) { + Text(connectionStatusToLabel(status: self.model.status)) } - .accentColor(colorScheme == .dark ? .white : .black) + .accentColor(self.colorScheme == .dark ? .white : .black) } .frame(maxWidth: 300) .padding() .navigationTitle("Connecting to Server") } - else { - serverView - .environment(model) + else if self.model.status == .loggedIn { + self.serverView + .environment(self.model) .onChange(of: Prefs.shared.userIconID) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.username) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.refusePrivateMessages) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.refusePrivateChat) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.enableAutomaticMessage) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.automaticMessage) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .toolbar { if #available(macOS 26.0, *) { @@ -196,7 +163,7 @@ struct ServerView: View { .resizable() .scaledToFit() .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0) } .sharedBackgroundVisibility(.hidden) } @@ -206,7 +173,7 @@ struct ServerView: View { .resizable() .scaledToFit() .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0) } } } @@ -214,164 +181,54 @@ struct ServerView: View { } .onDisappear { Task { - await model.disconnect() + await self.model.disconnect() } } - .onChange(of: model.serverTitle) { - state.serverName = model.serverTitle + .onChange(of: self.model.serverTitle) { + self.state.serverName = self.model.serverTitle } -// .onChange(of: model.bannerImage) { -// state.serverBanner = model.bannerImage -// } -// .onChange(of: model.bannerColors) { -// guard let backgroundColor = model.bannerColors?.backgroundColor else { -// state.bannerBackgroundColor = nil -// return -// } -// state.bannerBackgroundColor = Color(nsColor: backgroundColor) -// } - .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { + .alert(self.model.errorMessage ?? "Server Error", isPresented: self.$model.errorDisplayed) { Button("OK") {} } - .task { - var address = server.address - if server.port != HotlinePorts.DefaultServerPort { - address += ":\(server.port)" + .onAppear { + var address = self.server.address + if self.server.port != HotlinePorts.DefaultServerPort { + address += ":\(self.server.port)" } - connectAddress = server.address - connectLogin = server.login - connectPassword = server.password + self.connectAddress = self.server.address + self.connectLogin = self.server.login + self.connectPassword = self.server.password // Connect to server automatically unless the option key is held down. if !NSEvent.modifierFlags.contains(.option) { - connectToServer() + self.connectToServer() } + + self.connectionDisplayed = true } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } - var connectForm: some View { - VStack(alignment: .center, spacing: 0) { - Form { - HStack(alignment: .top, spacing: 10) { - Image("Server Large") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Connect to Server") - Text("Enter the address of a Hotline server to connect to.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - - TextField(text: $connectAddress) { - Text("Address") - } - .focused($focusedField, equals: .address) - - TextField(text: $connectLogin, prompt: Text("Optional")) { - Text("Login") - } - .focused($focusedField, equals: .login) - - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password") - } - .focused($focusedField, equals: .password) - } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) - - HStack { - Button { - if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - connectNameSheetPresented = true - } - } label: { - Image(systemName: "bookmark.fill") - } - .disabled(connectAddress.isEmpty) - .controlSize(.regular) - .buttonStyle(.automatic) - .help("Bookmark server") - - Spacer() - - Button("Cancel") { - dismiss() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.cancelAction) - - Button("Connect") { - connectToServer() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.defaultAction) - } - .padding(.horizontal, 20) + private var connectForm: some View { + ConnectView(address: self.$connectAddress, login: self.$connectLogin, password: self.$connectPassword) { + self.connectToServer() } - .onChange(of: connectAddress) { - let (a, p) = Server.parseServerAddressAndPort(connectAddress) - server.address = a - server.port = p + .focusSection() + .onChange(of: self.connectAddress) { + let (a, p) = Server.parseServerAddressAndPort(self.connectAddress) + self.server.address = a + self.server.port = p } - .onChange(of: connectLogin) { - server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + .onChange(of: self.connectLogin) { + self.server.login = self.connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) } - .onChange(of: connectPassword) { - server.password = connectPassword - } - .frame(maxWidth: 380) - .padding() - .sheet(isPresented: $connectNameSheetPresented) { - VStack(alignment: .leading) { - Text("Save Bookmark") - .foregroundStyle(.secondary) - .padding(.bottom, 4) - TextField("Bookmark Name", text: $connectName) - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - .frame(width: 250) - .padding() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - connectNameSheetPresented = false - connectName = "" - } - } - - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - let name = String(connectName.trimmingCharacters(in: .whitespacesAndNewlines)) - if !name.isEmpty { - connectNameSheetPresented = false - connectName = "" - - let (host, port) = Server.parseServerAddressAndPort(connectAddress) - let login: String? = connectLogin.isEmpty ? nil : connectLogin - let password: String? = connectPassword.isEmpty ? nil : connectPassword - - if !host.isEmpty { - let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) - Bookmark.add(newBookmark, context: modelContext) - } - } - } - } - } + .onChange(of: self.connectPassword) { + self.server.password = self.connectPassword } } - var navigationList: some View { + private var navigationList: some View { List(selection: $state.selection) { // Don't show news on older servers. ForEach(model.serverVersion < 151 ? ServerView.classicMenuItems : ServerView.menuItems) { menuItem in @@ -496,7 +353,7 @@ struct ServerView: View { case .accounts: AccountManagerView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Accounts") + .navigationSubtitle("Administration") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) @@ -510,21 +367,21 @@ struct ServerView: View { } } .toolbar(removing: .sidebarToggle) - } - // MARK: - @MainActor func connectToServer() { - guard !server.address.isEmpty else { + guard !self.server.address.isEmpty else { return } + + // Set status here so it's immediate (not waiting to enter task). + self.model.status = .connecting Task { @MainActor in do { - // login() handles everything: connect, getUserList, sendPreferences, downloadBanner - try await model.login( + try await self.model.login( server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID -- cgit