diff options
19 files changed, 357 insertions, 311 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index aa10d51..57e7032 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,9 +22,9 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; - DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */; }; - DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */; }; - DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */; }; + DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; + DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B42EBA8A450010784E /* FilePreviewState.swift */; }; DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */; platformFilters = (macos, ); }; DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */; }; @@ -49,11 +49,11 @@ DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.swift */; }; DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */; }; DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B02EB2708E00DCB941 /* HotlineState.swift */; }; - DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */; }; + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */; }; DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B42EB6840A00DCB941 /* TransfersView.swift */; }; DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */; }; DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B92EB91B5E00DCB941 /* FileProgress.swift */; }; - DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */; }; + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; @@ -136,9 +136,9 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = "<group>"; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = "<group>"; }; - DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClientNew.swift; sourceTree = "<group>"; }; - DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClientNew.swift; sourceTree = "<group>"; }; - DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClientNew.swift; sourceTree = "<group>"; }; + DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = "<group>"; }; + DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = "<group>"; }; + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = "<group>"; }; DA3429B42EBA8A450010784E /* FilePreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewState.swift; sourceTree = "<group>"; }; 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>"; }; @@ -162,11 +162,11 @@ DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = "<group>"; }; DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientNew.swift; sourceTree = "<group>"; }; DA5268B02EB2708E00DCB941 /* HotlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineState.swift; sourceTree = "<group>"; }; - DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClientNew.swift; sourceTree = "<group>"; }; + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClient.swift; sourceTree = "<group>"; }; DA5268B42EB6840A00DCB941 /* TransfersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransfersView.swift; sourceTree = "<group>"; }; DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferRateEstimator.swift; sourceTree = "<group>"; }; DA5268B92EB91B5E00DCB941 /* FileProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProgress.swift; sourceTree = "<group>"; }; - DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClientNew.swift; sourceTree = "<group>"; }; + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClient.swift; sourceTree = "<group>"; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = "<group>"; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = "<group>"; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; @@ -248,11 +248,11 @@ isa = PBXGroup; children = ( DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, - DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, - DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, - DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, - DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */, - DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */, + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */, + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */, + DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */, + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */, + DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */, ); path = Transfers; sourceTree = "<group>"; @@ -639,9 +639,9 @@ DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, - DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, - DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, + DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */, @@ -672,7 +672,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, - DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, @@ -706,7 +706,7 @@ DA501BF02EBED848001714F8 /* BonjourState.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, - DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, + DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, @@ -723,7 +723,7 @@ DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, - DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */, + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 854c7ff..cd34d9e 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -93,11 +93,11 @@ public struct HotlineServerInfo: Sendable { // MARK: - Hotline Client -/// Modern async/await-based Hotline protocol client +/// A client for connecting to and interacting with Hotline servers. /// /// Example usage: /// ```swift -/// let client = try await HotlineClientNew.connect( +/// let client = try await HotlineClient.connect( /// host: "server.example.com", /// port: 5500, /// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) @@ -123,7 +123,7 @@ public struct HotlineServerInfo: Sendable { /// // Get user list /// let users = try await client.getUserList() /// ``` -public actor HotlineClientNew { +public actor HotlineClient { // MARK: - Properties private let socket: NetSocket @@ -189,23 +189,23 @@ public actor HotlineClientNew { host: String, port: UInt16 = 5500, login: HotlineLogin - ) async throws -> HotlineClientNew { - print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") + ) async throws -> HotlineClient { + print("HotlineClient.connect(): Starting connection to \(host):\(port) as '\(login.username)'") // Connect socket - print("HotlineClientNew.connect(): Connecting socket...") + print("HotlineClient.connect(): Connecting socket...") let socket = try await NetSocket.connect(host: host, port: port) - print("HotlineClientNew.connect(): Socket connected") + print("HotlineClient.connect(): Socket connected") // Perform handshake - print("HotlineClientNew.connect(): Sending handshake...") + print("HotlineClient.connect(): Sending handshake...") try await socket.write(handshakeData) let handshakeResponse = try await socket.read(8) - print("HotlineClientNew.connect(): Handshake response received") + print("HotlineClient.connect(): Handshake response received") // Verify handshake guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { - print("HotlineClientNew.connect(): Invalid handshake response") + print("HotlineClient.connect(): Invalid handshake response") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: -1, userInfo: [ NSLocalizedDescriptionKey: "Invalid handshake response" @@ -215,7 +215,7 @@ public actor HotlineClientNew { let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } guard errorCode.bigEndian == 0 else { - print("HotlineClientNew.connect(): Handshake failed with error code \(errorCode)") + print("HotlineClient.connect(): Handshake failed with error code \(errorCode)") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" @@ -224,24 +224,24 @@ public actor HotlineClientNew { } // Create client - print("HotlineClientNew.connect(): Creating client instance") - let client = HotlineClientNew(socket: socket) + print("HotlineClient.connect(): Creating client instance") + let client = HotlineClient(socket: socket) // Start receive loop - print("HotlineClientNew.connect(): Starting receive loop") + print("HotlineClient.connect(): Starting receive loop") await client.startReceiveLoop() // Perform login - print("HotlineClientNew.connect(): Performing login") + print("HotlineClient.connect(): Performing login") let serverInfo = try await client.performLogin(login) await client.setServerInfo(serverInfo) - print("HotlineClientNew.connect(): Login successful") + print("HotlineClient.connect(): Login successful") // Start keep-alive - print("HotlineClientNew.connect(): Starting keep-alive") + print("HotlineClient.connect(): Starting keep-alive") await client.startKeepAlive() - print("HotlineClientNew.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") return client } @@ -296,19 +296,19 @@ public actor HotlineClientNew { isConnected = false - print("HotlineClientNew.disconnect(): Starting disconnect") + print("HotlineClient.disconnect(): Starting disconnect") self.receiveTask?.cancel() self.keepAliveTask?.cancel() await self.socket.close() self.failAllPendingTransactions(HotlineClientError.notConnected) self.eventContinuation.finish() - print("HotlineClientNew.disconnect(): Disconnect complete") + print("HotlineClient.disconnect(): Disconnect complete") } // MARK: - Receive Loop private func startReceiveLoop() { - print("HotlineClientNew.startReceiveLoop(): Creating receive task") + print("HotlineClient.startReceiveLoop(): Creating receive task") self.receiveTask = Task { [weak self] in guard let self else { return @@ -320,21 +320,21 @@ public actor HotlineClientNew { let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) await self.handleTransaction(transaction) } - print("HotlineClientNew.startReceiveLoop(): Task cancelled, exiting loop") + print("HotlineClient.startReceiveLoop(): Task cancelled, exiting loop") } catch { if Task.isCancelled || error is CancellationError { - print("HotlineClientNew.startReceiveLoop(): Receive loop cancelled") + print("HotlineClient.startReceiveLoop(): Receive loop cancelled") } else { - print("HotlineClientNew.startReceiveLoop(): Receive loop error: \(error)") + print("HotlineClient.startReceiveLoop(): Receive loop error: \(error)") await self.disconnect() } } - print("HotlineClientNew.startReceiveLoop(): Receive loop ended") + print("HotlineClient.startReceiveLoop(): Receive loop ended") } } private func handleTransaction(_ transaction: HotlineTransaction) { - print("HotlineClientNew: <= \(transaction.type) [\(transaction.id)]") + print("HotlineClient: <= \(transaction.type) [\(transaction.id)]") // Check if this is a reply to a pending transaction if transaction.isReply == 1 || transaction.type == .reply { @@ -348,7 +348,7 @@ public actor HotlineClientNew { private func handleReply(_ transaction: HotlineTransaction) { guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { - print("HotlineClientNew: Received reply for unknown transaction \(transaction.id)") + print("HotlineClient: Received reply for unknown transaction \(transaction.id)") return } @@ -359,7 +359,6 @@ public actor HotlineClientNew { message: errorText )) } else { - print("HELLO") continuation.resume(returning: transaction) } } @@ -417,14 +416,15 @@ public actor HotlineClientNew { } default: - print("HotlineClientNew: Unhandled event type \(transaction.type)") + print("HotlineClient: Unhandled event type \(transaction.type)") } } // MARK: - Transaction Sending + @discardableResult private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { - print("HotlineClientNew: => \(transaction.type) [\(transaction.id)]") + print("HotlineClient: => \(transaction.type) [\(transaction.id)]") let transactionID = transaction.id @@ -518,7 +518,7 @@ public actor HotlineClientNew { let _ = try? await self.getUserList() } } catch { - print("HotlineClientNew: Keep-alive failed: \(error)") + print("HotlineClient: Keep-alive failed: \(error)") } } @@ -605,7 +605,7 @@ public actor HotlineClientNew { try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Files + // MARK: - Files /// Get the file list for a directory /// @@ -617,7 +617,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var files: [HotlineFile] = [] for field in reply.getFieldList(type: .fileNameWithInfo) { @@ -649,7 +649,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSize = reply.getField(type: .transferSize)?.getInteger(), @@ -664,7 +664,7 @@ public actor HotlineClientNew { return (referenceNumber, transferSize, fileSize, waitingCount) } - // MARK: - Public API - News + // MARK: - News /// Get news categories at a path /// @@ -676,7 +676,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var categories: [HotlineNewsCategory] = [] for field in reply.getFieldList(type: .newsCategoryListData15) { @@ -698,7 +698,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let articleData = reply.getField(type: .newsArticleListData) else { return [] @@ -725,7 +725,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleID, val: id) transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) return reply.getField(type: .newsArticleData)?.getString() } @@ -754,17 +754,17 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) transaction.setFieldString(type: .newsArticleData, val: text) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } - // MARK: - Public API - Message Board + // MARK: - Message Board /// Get message board posts /// /// - Returns: Array of message strings public func getMessageBoard() async throws -> [String] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let text = reply.getField(type: .data)?.getString() else { return [] @@ -784,10 +784,10 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) - try await socket.send(transaction, endian: .big) + try await self.socket.send(transaction, endian: .big) } - // MARK: - Public API - File Operations + // MARK: - File Operations /// Get detailed information about a file /// @@ -840,7 +840,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) do { - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) return true } catch { return false @@ -854,7 +854,7 @@ public actor HotlineClientNew { /// - Returns: Array of user accounts sorted by login public func getAccounts() async throws -> [HotlineAccount] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) let accountFields = reply.getFieldList(type: .data) var accounts: [HotlineAccount] = [] @@ -886,7 +886,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Update an existing user account (requires admin access) @@ -919,7 +919,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password!) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Delete a user account (requires admin access) @@ -929,7 +929,7 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) transaction.setFieldEncodedString(type: .userLogin, val: login) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } // MARK: - Banners @@ -940,7 +940,7 @@ public actor HotlineClientNew { /// - Throws: HotlineClientError if not connected or server doesn't support banners public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -972,7 +972,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1000,7 +1000,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1027,7 +1027,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), @@ -1046,7 +1046,7 @@ public actor HotlineClientNew { /// - path: Directory path where the folder should be uploaded /// - Returns: Reference number for the upload transfer public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? { - print("HotlineClientNew: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") + print("HotlineClient: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder) transaction.setFieldString(type: .fileName, val: name) @@ -1054,7 +1054,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .transferSize, val: totalSize) transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift index 82f61d4..82f61d4 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift index 5cf5628..8f3088d 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift @@ -8,23 +8,29 @@ public class HotlineFilePreviewClient { private let referenceNumber: UInt32 private let fileName: String private let transferSize: UInt32 + private let fileType: String? + private let fileCreator: String? private var downloadClient: HotlineFileDownloadClient? private var previewTask: Task<URL, Error>? private var temporaryFileURL: URL? - + public init( fileName: String, address: String, port: UInt16, reference: UInt32, - size: UInt32 + size: UInt32, + fileType: String? = nil, + fileCreator: String? = nil ) { self.fileName = fileName self.serverAddress = address self.serverPort = port self.referenceNumber = reference self.transferSize = size + self.fileType = fileType + self.fileCreator = fileCreator } // MARK: - API @@ -105,13 +111,23 @@ public class HotlineFilePreviewClient { progressHandler?(.connected) // Stream raw data directly to temp file with progress tracking - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file") - - let totalSize = Int(transferSize) + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(self.transferSize) bytes to temp file") - // Create empty file - FileManager.default.createFile(atPath: tempFileURL.path(percentEncoded: false), contents: nil) + let totalSize = Int(self.transferSize) + // Create empty file (with HFS attributes if available) + var attributes: [FileAttributeKey: Any] = [:] + if let creator = self.fileCreator, !creator.isBlank { + attributes[.hfsCreatorCode] = creator.fourCharCode() as NSNumber + } + if let type = self.fileType, !type.isBlank { + attributes[.hfsTypeCode] = type.fourCharCode() as NSNumber + } + + guard FileManager.default.createFile(atPath: tempFileURL.path, contents: nil, attributes: attributes) else { + throw HotlineTransferClientError.failedToTransfer + } + let fileHandle = try FileHandle(forWritingTo: tempFileURL) defer { try? fileHandle.close() } diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift index 384e9bd..384e9bd 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift index 600b9f2..36193bd 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift @@ -9,7 +9,7 @@ public struct HotlineFolderItemProgress: Sendable { } @MainActor -public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { +public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -22,8 +22,6 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { private var downloadTask: Task<URL, Error>? private var folderProgress: Progress? - // MARK: - Initialization - public init( address: String, port: UInt16, @@ -36,11 +34,9 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { self.referenceNumber = reference self.transferTotal = Int(size) self.folderItemCount = itemCount - - print("HotlineFolderDownloadClientNew[\(reference)]: Server reported transferSize=\(size) bytes, folderItemCount=\(itemCount) items") } - // MARK: - Public API + // MARK: - API public func download( to location: HotlineDownloadLocation, @@ -63,7 +59,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { self.downloadTask = nil return url } catch { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Failed to download folder: \(error)") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)") self.downloadTask = nil progressHandler?(.error(error)) throw error @@ -82,7 +78,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { } } - // MARK: - Private Implementation + // MARK: - Implementation private func performDownload( to destination: HotlineDownloadLocation, @@ -113,7 +109,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { destinationFilename = destinationURL.lastPathComponent } - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") // Create destination folder try? fm.removeItem(at: destinationURL) @@ -127,7 +123,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { self.folderProgress = progress // Send initial magic header - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -157,14 +153,14 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { } let joinedPath = pathComponents.joined(separator: "/") - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") if itemType == 1 { // Folder entry - no progress shown for folder creation if !pathComponents.isEmpty { let folderURL = destinationURL.appendingPathComponents(pathComponents) try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Created folder at \(folderURL.path)") } completedItemCount += 1 @@ -187,7 +183,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { let fileSize = fileSizeData.readUInt32(at: 0)! totalBytesTransferred += 4 - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") // Notify item progress before download starts completedItemCount += 1 @@ -213,7 +209,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { totalBytesTransferred += fileBytesRead self.transferSize = totalBytesTransferred - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloaded file to \(fileURL.path)") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)") // Request next item if not done if completedItemCount < folderItemCount { @@ -222,7 +218,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { } else { // Unknown item type - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Unknown item type \(itemType), skipping") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping") completedItemCount += 1 if completedItemCount < folderItemCount { @@ -231,7 +227,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { } } - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Download complete!") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Download complete!") // Ensure folder progress shows 100% complete self.folderProgress?.completedUnitCount = Int64(self.transferTotal) @@ -244,14 +240,14 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Helper Methods private func connectToTransferServer() async throws -> NetSocket { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") let socket = try await NetSocket.connect( host: self.serverAddress, port: self.serverPort + 1 ) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!") return socket } @@ -260,7 +256,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { action.rawValue } try await socket.write(actionData) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sent action: \(action)") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Sent action: \(action)") } private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { @@ -314,7 +310,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { let totalBytesNow = totalBytesTransferredSoFar + bytesRead self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File has \(header.forkCount) forks") + print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks") var resourceForkData: Data? var fileHandle: FileHandle? @@ -340,7 +336,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { if forkHeader.isInfoFork { // Read INFO fork - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Reading INFO fork (\(forkHeader.dataSize) bytes)") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading INFO fork (\(forkHeader.dataSize) bytes)") let infoData = try await socket.read(Int(forkHeader.dataSize)) bytesRead += infoData.count @@ -366,9 +362,10 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { try? fm.removeItem(at: finalURL) fileHandle = try fm.createHotlineFile(at: finalURL, infoFork: info) - } else if forkHeader.isDataFork { + } + else if forkHeader.isDataFork { // Stream DATA fork to disk - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)") guard let fh = fileHandle else { throw HotlineTransferClientError.failedToTransfer diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift index 15636f3..1947de6 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift @@ -16,16 +16,12 @@ private struct FolderItem { } @MainActor -public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration - +public class HotlineFolderUploadClient: @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 HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { private var socket: NetSocket? private var uploadTask: Task<Void, Error>? - // MARK: - Initialization - public init?( folderURL: URL, address: String, @@ -108,7 +102,7 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { } } - // MARK: - + // MARK: - Implementation private enum UploadStage { case waitingForNextFile // Waiting for server to send .nextFile action diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index bb28370..cf9f8ff 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -2,23 +2,85 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +extension FileManager { + @discardableResult + func moveToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.moveItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } + + @discardableResult + func copyToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.copyItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } +} + +// MARK: - + +extension View { + @ViewBuilder + func applyNavigationDocumentIfPresent(_ url: URL?) -> some View { + if let url { + self.navigationDocument(url) + } else { + self + } + } +} + +// MARK: - + extension Data { func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - try? h.close() - if bounceDock { - #if os(macOS) - var downloadURL = URL(filePath: filePath) - downloadURL.resolveSymlinksInPath() - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif - } - return true + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + + if FileManager.default.createFile(atPath: filePath, contents: self) { + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif } + return true + +// if FileManager.default.createFile(atPath: filePath, contents: nil) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// try? h.write(contentsOf: self) +// try? h.close() +// +// } } return false } diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index e3081c4..a2f0ea5 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -49,13 +49,19 @@ import UniformTypeIdentifiers var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension.lowercased() + var fileExtension = (self.name as NSString).pathExtension.lowercased() + if fileExtension.isEmpty && !self.type.isEmpty { + let type = self.type.lowercased() + if let ext = FileManager.HFSTypeToExtension[type] { + fileExtension = ext + } + } + if let fileType = UTType(filenameExtension: fileExtension) { if fileType.canBePreviewedByQuickLook { return true } - print("FILE TYPE?", fileType, fileExtension, fileType.isSubtype(of: .pdf), fileType.isSupertype(of: .pdf)) if fileType.isSubtype(of: .image) { return true } diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index dcf5314..7e05a97 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -7,6 +7,9 @@ struct PreviewFileInfo: Identifiable, Codable { var size: Int var name: String + var type: String? = nil + var creator: String? = nil + var previewType: FilePreviewType { let fileExtension = (self.name as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 601859e..558a296 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -35,8 +35,6 @@ final class AppUpdate { case manual } - // MARK: - Public State - var isChecking = false var isDownloading = false var showWindow = false @@ -55,7 +53,7 @@ final class AppUpdate { private let remindDateKey = "update.remind.date" private let lastPromptedVersionKey = "update.last.prompt.version" - // MARK: - Public API + // MARK: - API func checkForUpdatesOnLaunch() async { await checkForUpdates(trigger: .automatic) @@ -100,7 +98,7 @@ final class AppUpdate { resetAndCloseWindow() } - // MARK: - Internal Logic + // MARK: - Implementation private func checkForUpdates(trigger: CheckTrigger) async { await MainActor.run { @@ -259,8 +257,7 @@ final class AppUpdate { private func downloadRelease(_ release: UpdateReleaseInfo) async { do { let (temporaryURL, _) = try await URLSession.shared.download(from: release.downloadURL) - let downloadsDirectory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - let destinationURL = downloadsDirectory.appendingPathComponent(release.assetName) + let destinationURL = URL.downloadsDirectory.appendingPathComponent(release.assetName) if FileManager.default.fileExists(atPath: destinationURL.path) { try? FileManager.default.removeItem(at: destinationURL) diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift index 27f8199..ec79f66 100644 --- a/Hotline/State/FilePreviewState.swift +++ b/Hotline/State/FilePreviewState.swift @@ -1,8 +1,6 @@ import SwiftUI import UniformTypeIdentifiers -// MARK: - Preview Type - enum FilePreviewType: Equatable { case unknown case image @@ -13,13 +11,15 @@ enum FilePreviewType: Equatable { @MainActor @Observable final class FilePreviewState { - // MARK: - Properties - + enum LoadState: Equatable { + case unloaded + case loading + case loaded + case failed + } + let info: PreviewFileInfo - private var previewClient: HotlineFilePreviewClient? - private var previewTask: Task<Void, Never>? - var state: LoadState = .unloaded var progress: Double = 0.0 @@ -33,39 +33,35 @@ final class FilePreviewState { var text: String? = nil var styledText: NSAttributedString? = nil - - // MARK: - Computed Properties + + @ObservationIgnored private var previewClient: HotlineFilePreviewClient? + @ObservationIgnored private var previewTask: Task<Void, Never>? var previewType: FilePreviewType { - info.previewType + self.info.previewType } - // MARK: - Initialization - init(info: PreviewFileInfo) { self.info = info } - nonisolated deinit { - // Note: Can't access @MainActor properties from deinit - // Cleanup will happen when previewClient is deallocated - } - - // MARK: - Public API + // MARK: - API func download() { // Cancel any existing download - previewTask?.cancel() - previewClient?.cleanup() + self.previewTask?.cancel() + self.previewClient?.cleanup() let task = Task { @MainActor in do { let client = HotlineFilePreviewClient( - fileName: info.name, - address: info.address, - port: UInt16(info.port), - reference: info.id, - size: UInt32(info.size) + fileName: self.info.name, + address: self.info.address, + port: UInt16(self.info.port), + reference: self.info.id, + size: UInt32(self.info.size), + fileType: self.info.type, + fileCreator: self.info.creator ) self.previewClient = client @@ -132,69 +128,58 @@ final class FilePreviewState { } func cancel() { - previewTask?.cancel() - previewTask = nil - previewClient?.cancel() + self.previewTask?.cancel() + self.previewTask = nil + self.previewClient?.cancel() } func cleanup() { - previewClient?.cleanup() - previewClient = nil - fileURL = nil - image = nil - text = nil - styledText = nil + self.previewClient?.cleanup() + self.previewClient = nil + self.fileURL = nil + self.image = nil + self.text = nil + self.styledText = nil } - // MARK: - Private Implementation + // MARK: - Utility - private func loadPreview(from url: URL) { - guard let data = try? Data(contentsOf: url) else { - self.state = .failed - print("FilePreviewState: Failed to read preview data from \(url.path)") - return - } - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - - if self.image == nil { - self.state = .failed - print("FilePreviewState: Failed to create image from data") - } - - case .text: - let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) - if encoding != 0 { - self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) - } else { - self.text = String(data: data, encoding: .utf8) - } - - if self.text == nil { - self.state = .failed - print("FilePreviewState: Failed to decode text data") - } - - case .unknown: - print("FilePreviewState: Unknown preview type for \(info.name)") - break - } - } -} - -// MARK: - Load State - -extension FilePreviewState { - enum LoadState: Equatable { - case unloaded - case loading - case loaded - case failed - } +// private func loadPreview(from url: URL) { +// guard let data = try? Data(contentsOf: url) else { +// self.state = .failed +// print("FilePreviewState: Failed to read preview data from \(url.path)") +// return +// } +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// +// if self.image == nil { +// self.state = .failed +// print("FilePreviewState: Failed to create image from data") +// } +// +// case .text: +// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) +// if encoding != 0 { +// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) +// } else { +// self.text = String(data: data, encoding: .utf8) +// } +// +// if self.text == nil { +// self.state = .failed +// print("FilePreviewState: Failed to decode text data") +// } +// +// case .unknown: +// print("FilePreviewState: Unknown preview type for \(info.name)") +// break +// } +// } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e80571a..f814818 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -252,7 +252,7 @@ class HotlineState: Equatable { // MARK: - Private State - @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var client: HotlineClient? @ObservationIgnored private var eventTask: Task<Void, Never>? @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @@ -308,13 +308,13 @@ class HotlineState: Equatable { iconID: UInt16(iconID) ) - print("HotlineState.login(): Calling HotlineClientNew.connect()...") - let client = try await HotlineClientNew.connect( + print("HotlineState.login(): Calling HotlineClient.connect()...") + let client = try await HotlineClient.connect( host: server.address, port: UInt16(server.port), login: loginInfo ) - print("HotlineState.login(): HotlineClientNew.connect() returned") + print("HotlineState.login(): HotlineClient.connect() returned") self.client = client print("HotlineState.login(): Client stored") @@ -865,7 +865,7 @@ class HotlineState: Equatable { /// - progressCallback: Optional callback for progress updates (receives TransferInfo and progress 0.0-1.0) /// - callback: Optional completion callback (receives TransferInfo and final file URL) @MainActor - func downloadFileNew(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { guard let client = self.client else { return } var fullPath: [String] = [] @@ -975,7 +975,7 @@ class HotlineState: Equatable { /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info) /// - callback: Optional completion callback (receives TransferInfo and final folder URL) @MainActor - func downloadFolderNew( + func downloadFolder( _ folderName: String, path: [String], to destination: URL? = nil, @@ -1018,7 +1018,7 @@ class HotlineState: Equatable { AppState.shared.addTransfer(transfer) // Create download client - let downloadClient = HotlineFolderDownloadClientNew( + let downloadClient = HotlineFolderDownloadClient( address: address, port: UInt16(port), reference: referenceNumber, @@ -1091,7 +1091,7 @@ class HotlineState: Equatable { } } - /// Modern async/await folder upload using HotlineFolderUploadClientNew + /// Upload a folder to the server. /// /// - Parameters: /// - folderURL: URL to the folder on disk to upload @@ -1155,7 +1155,7 @@ class HotlineState: Equatable { print("HotlineState: Got folder upload reference: \(referenceNumber)") // Create upload client - guard let uploadClient = HotlineFolderUploadClientNew( + guard let uploadClient = HotlineFolderUploadClient( folderURL: folderURL, address: address, port: UInt16(port), @@ -1346,9 +1346,9 @@ class HotlineState: Equatable { } func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClientNew + // TODO: Implement setFileInfo in HotlineClient // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + print("setFileInfo not yet implemented in HotlineState/HotlineClient") } @MainActor diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 805672d..5913bf5 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 "Admin" + return "Accounts" case .user(let userID): return String(userID) } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift index 26bd286..a504a7a 100644 --- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -11,16 +11,16 @@ struct FilePreviewQuickLookView: View { @Environment(\.dismiss) private var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreviewState? = nil + @State private var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { Group { - if preview?.state != .loaded { + if self.preview?.state != .loaded { VStack(alignment: .center, spacing: 0) { Spacer() - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + ProgressView(value: max(0.0, min(1.0, self.preview?.progress ?? 0.0))) .focusable(false) .progressViewStyle(.circular) .controlSize(.extraLarge) @@ -33,7 +33,7 @@ struct FilePreviewQuickLookView: View { .padding() } else { - if let fileURL = preview?.fileURL { + if let fileURL = self.preview?.fileURL { QuickLookPreviewView(fileURL: fileURL) .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) } @@ -68,25 +68,15 @@ struct FilePreviewQuickLookView: View { .focusable() .focusEffectDisabled() .background(Color(nsColor: .textBackgroundColor)) - .focused($focusField, equals: .window) - .navigationTitle(info?.name ?? "File Preview") - .background { - if let fileURL = self.preview?.fileURL { - WindowConfigurator { window in - window.representedURL = fileURL - window.standardWindowButton(.documentIconButton)?.isHidden = false - } - } - } + .focused(self.$focusField, equals: .window) + .navigationTitle(self.info?.name ?? "File Preview") + .applyNavigationDocumentIfPresent(self.preview?.fileURL) .toolbar { - if let _ = preview?.fileURL { + if let fileURL = self.preview?.fileURL { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - if let fileURL = preview?.fileURL, - let data = try? Data(contentsOf: fileURL) { - let _ = data.saveAsFileToDownloads(filename: info.name) - } + FileManager.default.copyToDownloads(from: fileURL, using: info.name, bounceDock: true) } label: { Label("Download File...", systemImage: "arrow.down") } @@ -96,28 +86,27 @@ struct FilePreviewQuickLookView: View { } } .task { - if let info = info { - preview = FilePreviewState(info: info) - preview?.download() + if let info = self.info { + self.preview = FilePreviewState(info: info) + self.preview?.download() } } .onAppear { - if info == nil { - Task { - dismiss() - } + guard self.info != nil else { + self.dismiss() return } - focusField = .window + self.focusField = .window } .onDisappear { - preview?.cancel() - dismiss() + self.preview?.cancel() + self.preview?.cleanup() + self.dismiss() } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() + .onChange(of: self.preview?.state) { + if self.preview?.state == .failed { + self.dismiss() } } .preferredColorScheme(.dark) diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index d4ba5c8..984b120 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -376,11 +376,11 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) return } } @@ -397,10 +397,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) + model.downloadFolder(file.name, path: file.path) } else { - model.downloadFileNew(file.name, path: file.path) + model.downloadFile(file.name, path: file.path) } } @@ -442,9 +442,12 @@ struct FilesView: View { return } - model.previewFile(file.name, path: file.path) { info in + self.model.previewFile(file.name, path: file.path) { info in if let info = info { - openPreviewWindow(info) + var extendedInfo = info + extendedInfo.creator = file.creator + extendedInfo.type = file.type + self.openPreviewWindow(extendedInfo) } } } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 2b1b695..9b13cc0 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -81,7 +81,7 @@ struct FolderItemView: View { .opacity(file.isUnavailable ? 0.5 : 1.0) if loading { - ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + ProgressView().controlSize(.mini).padding([.leading, .trailing], 5) } Spacer() if !file.isUnavailable { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 757dbd8..d4b3407 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -93,7 +93,7 @@ struct ServerView: View { 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: "Admin", image: "Section Users"), + ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -280,47 +280,43 @@ struct ServerView: View { } var transfersSection: some View { -// Section("Transfers") { - ForEach(model.transfers) { transfer in - TransferItemView(transfer: transfer) - } -// } + ForEach(model.transfers) { transfer in + TransferItemView(transfer: transfer) + } } var usersSection: some View { -// Section("\(model.users.count) Online") { - ForEach(model.users) { user in - HStack(spacing: 5) { - if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { - Image(nsImage: iconImage) - .frame(width: 16, height: 16) - .padding(.leading, 2) - .padding(.trailing, 2) - } - else { - Image("User") - .frame(width: 16, height: 16) - .padding(.leading, 2) - .padding(.trailing, 2) - } - - Text(user.name) - .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) - - Spacer() - - if model.hasUnreadInstantMessages(userID: user.id) { - Circle() - .frame(width: 6, height: 6) - .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) - .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) - } + ForEach(model.users) { user in + HStack(spacing: 5) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { + Image(nsImage: iconImage) + .frame(width: 16, height: 16) + .padding(.leading, 2) + .padding(.trailing, 2) + } + else { + Image("User") + .frame(width: 16, height: 16) + .padding(.leading, 2) + .padding(.trailing, 2) + } + + Text(user.name) + .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) + + Spacer() + + if model.hasUnreadInstantMessages(userID: user.id) { + Circle() + .frame(width: 6, height: 6) + .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) + .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) } - .opacity(user.isIdle ? 0.5 : 1.0) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - .tag(ServerNavigationType.user(userID: user.id)) } -// } + .opacity(user.isIdle ? 0.5 : 1.0) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .tag(ServerNavigationType.user(userID: user.id)) + } } var serverView: some View { @@ -353,7 +349,7 @@ struct ServerView: View { case .accounts: AccountManagerView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Administration") + .navigationSubtitle("Accounts") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 489a50b..bf8c8bd 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -19,9 +19,7 @@ struct TransfersView: View { ToolbarItem(placement: .primaryAction) { Button { if self.selectedTransfers.isEmpty { - if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { - NSWorkspace.shared.open(downloadsURL) - } + NSWorkspace.shared.open(URL.downloadsDirectory) } else { let fileURLs = self.selectedTransfers.compactMap(\.fileURL) |