diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-11-07 16:16:31 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-11-07 16:16:31 -0800 |
| commit | 6afa5551add4541f376867b3d6527df9a0f793f3 (patch) | |
| tree | a1fbdb6c0a71f621a01c2821fe5d340d5853f1ca | |
| parent | e1566598c96601ebcf3229aab22fc7a593ee1904 (diff) | |
Further UI tweraks. Transfers button in toolbar. Better empty states in places. Fix race condition with transactions ids (yikes)!
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 26 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClientNew.swift | 100 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineProtocol.swift | 10 | ||||
| -rw-r--r-- | Hotline/macOS/HotlinePanelView.swift | 12 | ||||
| -rw-r--r-- | Hotline/macOS/News/NewsView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/ServerBookmarkSheet.swift | 70 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerBookmarkServerView.swift | 4 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerBookmarkSheet.swift | 4 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerItemView.swift | 4 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerView.swift | 480 |
10 files changed, 653 insertions, 59 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index c63109a..2861aab 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -33,6 +33,10 @@ DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; DA501BE22EBE9018001714F8 /* GroupedIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE12EBE9018001714F8 /* GroupedIconView.swift */; }; + DA501BE42EBE9517001714F8 /* ServerBookmarkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */; }; + DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */; }; + DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE82EBE9589001714F8 /* TrackerItemView.swift */; }; + DA501BEB2EBE95B4001714F8 /* TrackerBookmarkServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */; }; DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */; platformFilters = (macos, ); }; DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689D2EB073A400DCB941 /* IconSettingsView.swift */; platformFilters = (macos, ); }; DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */; platformFilters = (macos, ); }; @@ -139,6 +143,10 @@ DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = "<group>"; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; DA501BE12EBE9018001714F8 /* GroupedIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedIconView.swift; sourceTree = "<group>"; }; + DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerBookmarkSheet.swift; sourceTree = "<group>"; }; + DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkSheet.swift; sourceTree = "<group>"; }; + DA501BE82EBE9589001714F8 /* TrackerItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerItemView.swift; sourceTree = "<group>"; }; + DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkServerView.swift; sourceTree = "<group>"; }; DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; }; DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = "<group>"; }; DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = "<group>"; }; @@ -291,6 +299,18 @@ path = Views; sourceTree = "<group>"; }; + DA501BE52EBE9520001714F8 /* Trackers */ = { + isa = PBXGroup; + children = ( + DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, + DA501BE82EBE9589001714F8 /* TrackerItemView.swift */, + DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */, + DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */, + DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */, + ); + path = Trackers; + sourceTree = "<group>"; + }; DA52689A2EB0737400DCB941 /* Settings */ = { isa = PBXGroup; children = ( @@ -497,10 +517,10 @@ DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, - DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, + DA501BE52EBE9520001714F8 /* Trackers */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, DA5268A92EB081DE00DCB941 /* News */, @@ -615,6 +635,7 @@ DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */, + DA501BE42EBE9517001714F8 /* ServerBookmarkSheet.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, @@ -654,11 +675,13 @@ 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, + DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */, + DA501BEB2EBE95B4001714F8 /* TrackerBookmarkServerView.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */, @@ -680,6 +703,7 @@ DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */, DAE734FD2B2E65E9000C56F6 /* MessageBoardView.swift in Sources */, DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, + DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 8642d42..303bd32 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -160,6 +160,13 @@ public actor HotlineClientNew { UInt16(0x0001) // Version UInt16(0x0002) // Sub-version }) + + // Transaction IDs + private var nextTransactionID: UInt32 = 1 + private func generateTransactionID() -> UInt32 { + defer { self.nextTransactionID += 1 } + return self.nextTransactionID + } // MARK: - Connection @@ -258,7 +265,7 @@ public actor HotlineClientNew { // MARK: - Login private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo { - var transaction = HotlineTransaction(type: .login) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .login) transaction.setFieldEncodedString(type: .userLogin, val: login.login) transaction.setFieldEncodedString(type: .userPassword, val: login.password) transaction.setFieldUInt16(type: .userIconID, val: login.iconID) @@ -494,28 +501,29 @@ public actor HotlineClientNew { // MARK: - Keep-Alive private func startKeepAlive() { - keepAliveTask = Task { [weak self] in + self.keepAliveTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 180_000_000_000) // 3 minutes - - guard let self else { return } - - do { - if let version = await self.serverInfo?.version, version >= 185 { - let transaction = HotlineTransaction(type: .connectionKeepAlive) - try await self.socket.send(transaction, endian: .big) - } else { - // Older servers: send getUserNameList as keep-alive - _ = try? await self.getUserList() - } - } catch { - print("HotlineClientNew: Keep-alive failed: \(error)") - } + await self?.sendKeepAlive() } } } + + private func sendKeepAlive() async { + do { + if let version = self.serverInfo?.version, version >= 185 { + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .connectionKeepAlive) + try await self.socket.send(transaction, endian: .big) + } else { + // Older servers: send getUserNameList as keep-alive + let _ = try? await self.getUserList() + } + } catch { + print("HotlineClientNew: Keep-alive failed: \(error)") + } + } - // MARK: - Public API - Chat + // MARK: - Chat /// Send a chat message to the server /// @@ -524,20 +532,20 @@ public actor HotlineClientNew { /// - encoding: Text encoding (default: UTF-8) /// - announce: Whether this is an announcement (admin only, default: false) public func sendChat(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws { - var transaction = HotlineTransaction(type: .sendChat) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .sendChat) transaction.setFieldString(type: .data, val: message, encoding: encoding) transaction.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0) try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Users + // MARK: - Users /// Get the list of users currently connected to the server /// /// - Returns: Array of connected users public func getUserList() async throws -> [HotlineUser] { - let transaction = HotlineTransaction(type: .getUserNameList) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getUserNameList) let reply = try await sendTransaction(transaction) var users: [HotlineUser] = [] @@ -555,7 +563,7 @@ public actor HotlineClientNew { /// - userID: Target user ID /// - encoding: Text encoding (default: UTF-8) public func sendInstantMessage(_ message: String, to userID: UInt16, encoding: String.Encoding = .utf8) async throws { - var transaction = HotlineTransaction(type: .sendInstantMessage) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .sendInstantMessage) transaction.setFieldUInt16(type: .userID, val: userID) transaction.setFieldUInt32(type: .options, val: 1) transaction.setFieldString(type: .data, val: message, encoding: encoding) @@ -576,7 +584,7 @@ public actor HotlineClientNew { options: HotlineUserOptions = [], autoresponse: String? = nil ) async throws { - var transaction = HotlineTransaction(type: .setClientUserInfo) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setClientUserInfo) transaction.setFieldString(type: .userName, val: username) transaction.setFieldUInt16(type: .userIconID, val: iconID) transaction.setFieldUInt16(type: .options, val: options.rawValue) @@ -588,13 +596,13 @@ public actor HotlineClientNew { try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Agreement + // MARK: - Agreement /// Send agreement acceptance to the server /// /// Call this after receiving `.agreementRequired` event. public func sendAgree() async throws { - let transaction = HotlineTransaction(type: .agreed) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .agreed) try await socket.send(transaction, endian: .big) } @@ -605,7 +613,7 @@ public actor HotlineClientNew { /// - Parameter path: Directory path (empty for root) /// - Returns: Array of files and folders public func getFileList(path: [String] = []) async throws -> [HotlineFile] { - var transaction = HotlineTransaction(type: .getFileNameList) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileNameList) if !path.isEmpty { transaction.setFieldPath(type: .filePath, val: path) } @@ -634,7 +642,7 @@ public actor HotlineClientNew { path: [String], preview: Bool = false ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { - var transaction = HotlineTransaction(type: .downloadFile) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -664,7 +672,7 @@ public actor HotlineClientNew { /// - Parameter path: Category path (empty for root) /// - Returns: Array of news categories public func getNewsCategories(path: [String] = []) async throws -> [HotlineNewsCategory] { - var transaction = HotlineTransaction(type: .getNewsCategoryNameList) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsCategoryNameList) if !path.isEmpty { transaction.setFieldPath(type: .newsPath, val: path) } @@ -686,7 +694,7 @@ public actor HotlineClientNew { /// - Parameter path: Category path /// - Returns: Array of news articles public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] { - var transaction = HotlineTransaction(type: .getNewsArticleNameList) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleNameList) if !path.isEmpty { transaction.setFieldPath(type: .newsPath, val: path) } @@ -713,7 +721,7 @@ public actor HotlineClientNew { /// - flavor: Content flavor (default: "text/plain") /// - Returns: Article content as string public func getNewsArticle(id: UInt32, path: [String], flavor: String = "text/plain") async throws -> String? { - var transaction = HotlineTransaction(type: .getNewsArticleData) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleData) transaction.setFieldPath(type: .newsPath, val: path) transaction.setFieldUInt32(type: .newsArticleID, val: id) transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) @@ -739,7 +747,7 @@ public actor HotlineClientNew { throw HotlineClientError.invalidResponse } - var transaction = HotlineTransaction(type: .postNewsArticle) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .postNewsArticle) transaction.setFieldPath(type: .newsPath, val: path) transaction.setFieldUInt32(type: .newsArticleID, val: parentID) transaction.setFieldString(type: .newsArticleTitle, val: title) @@ -756,7 +764,7 @@ public actor HotlineClientNew { /// /// - Returns: Array of message strings public func getMessageBoard() async throws -> [String] { - let transaction = HotlineTransaction(type: .getMessageBoard) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) let reply = try await sendTransaction(transaction) guard let text = reply.getField(type: .data)?.getString() else { @@ -774,7 +782,7 @@ public actor HotlineClientNew { public func postMessageBoard(_ text: String) async throws { guard !text.isEmpty else { return } - var transaction = HotlineTransaction(type: .oldPostNews) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) try await socket.send(transaction, endian: .big) @@ -789,7 +797,7 @@ public actor HotlineClientNew { /// - path: Directory path containing the file /// - Returns: File details or nil if not found public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { - var transaction = HotlineTransaction(type: .getFileInfo) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -828,7 +836,7 @@ public actor HotlineClientNew { /// - path: Directory path containing the item /// - Returns: True if deletion succeeded public func deleteFile(name: String, path: [String]) async throws -> Bool { - var transaction = HotlineTransaction(type: .deleteFile) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -840,13 +848,13 @@ public actor HotlineClientNew { } } - // MARK: - Public API - User Administration + // MARK: - Administration /// Get list of user accounts (requires admin access) /// /// - Returns: Array of user accounts sorted by login public func getAccounts() async throws -> [HotlineAccount] { - let transaction = HotlineTransaction(type: .getAccounts) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts) let reply = try await sendTransaction(transaction) let accountFields = reply.getFieldList(type: .data) @@ -869,7 +877,7 @@ public actor HotlineClientNew { /// - password: Optional password (nil for no password) /// - access: Access permissions bitmask public func createUser(name: String, login: String, password: String?, access: UInt64) async throws { - var transaction = HotlineTransaction(type: .newUser) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newUser) transaction.setFieldString(type: .userName, val: name) transaction.setFieldEncodedString(type: .userLogin, val: login) @@ -891,7 +899,7 @@ public actor HotlineClientNew { /// - password: Password update - nil to keep current, "" to remove, or new password string /// - access: Access permissions bitmask public func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { - var transaction = HotlineTransaction(type: .setUser) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setUser) transaction.setFieldString(type: .userName, val: name) transaction.setFieldUInt64(type: .userAccess, val: access) @@ -919,20 +927,20 @@ public actor HotlineClientNew { /// /// - Parameter login: Login username to delete public func deleteUser(login: String) async throws { - var transaction = HotlineTransaction(type: .deleteUser) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) transaction.setFieldEncodedString(type: .userLogin, val: login) _ = try await sendTransaction(transaction) } - // MARK: - Public API - Banner Download + // MARK: - Banners /// Request to download the server banner image /// /// - Returns: Tuple of (referenceNumber, transferSize) for the banner download /// - Throws: HotlineClientError if not connected or server doesn't support banners public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { - let transaction = HotlineTransaction(type: .downloadBanner) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner) let reply = try await sendTransaction(transaction) guard @@ -947,7 +955,7 @@ public actor HotlineClientNew { return (referenceNumber, transferSize) } - // MARK: Files + // MARK: - Transfers /// Request to download a file /// @@ -957,7 +965,7 @@ public actor HotlineClientNew { /// - preview: If true, request preview mode (smaller transfer) /// - Returns: Tuple of (referenceNumber, transferSize, fileSize, waitingCount) for the download public func downloadFile(name: String, path: [String], preview: Bool = false) async throws -> (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? { - var transaction = HotlineTransaction(type: .downloadFile) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -989,7 +997,7 @@ public actor HotlineClientNew { /// - path: Directory path containing the folder /// - Returns: Tuple of (referenceNumber, transferSize, itemCount, waitingCount) for the download public func downloadFolder(name: String, path: [String]) async throws -> (referenceNumber: UInt32, transferSize: Int, itemCount: Int, waitingCount: Int)? { - var transaction = HotlineTransaction(type: .downloadFolder) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFolder) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -1016,7 +1024,7 @@ public actor HotlineClientNew { /// - path: Directory path where the file should be uploaded /// - Returns: Reference number for the upload transfer public func uploadFile(name: String, path: [String]) async throws -> UInt32? { - var transaction = HotlineTransaction(type: .uploadFile) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -1041,7 +1049,7 @@ public actor HotlineClientNew { 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)") - var transaction = HotlineTransaction(type: .uploadFolder) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) transaction.setFieldUInt32(type: .transferSize, val: totalSize) diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 3870261..5fd06dc 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -731,12 +731,6 @@ struct HotlineTransactionField { struct HotlineTransaction { static let headerSize = 20 - static var sequenceID: UInt32 = 1 - - static func nextID() -> UInt32 { - HotlineTransaction.sequenceID += 1 - return HotlineTransaction.sequenceID - } var flags: UInt8 = 0 var isReply: UInt8 = 0 @@ -748,9 +742,9 @@ struct HotlineTransaction { var fields: [HotlineTransactionField] = [] - init(type: HotlineTransactionType) { + init(id: UInt32, type: HotlineTransactionType) { self.type = type - self.id = HotlineTransaction.nextID() + self.id = id } init?(from data: [UInt8]) { diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 7819c2f..81c24b7 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -119,6 +119,18 @@ struct HotlinePanelView: View { .disabled(self.activeServerState == nil) .help("Accounts") } + + Button { + self.openWindow(id: "transfers") + } + label: { + Image("Section Transfers") + .resizable() + .scaledToFit() + } + .buttonStyle(.plain) + .frame(width: 20, height: 20) + .help("File Transfers") SettingsLink(label: { Image("Section Settings") diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index a07a441..976a985 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -138,7 +138,7 @@ struct NewsView: View { ContentUnavailableView { Label("No News", systemImage: "newspaper") } description: { - Text("This server has no newsgroups") + Text("This server has not created any newsgroups yet") } } diff --git a/Hotline/macOS/Trackers/ServerBookmarkSheet.swift b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift new file mode 100644 index 0000000..6ad1657 --- /dev/null +++ b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct ServerBookmarkSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var bookmark: Bookmark + @State private var serverName: String = "" + @State private var serverAddress: String = "" + @State private var serverLogin: String = "" + @State private var serverPassword: String = "" + + init(_ editingBookmark: Bookmark) { + _bookmark = .init(initialValue: editingBookmark) + _serverName = .init(initialValue: editingBookmark.name) + _serverAddress = .init(initialValue: editingBookmark.displayAddress) + _serverLogin = .init(initialValue: editingBookmark.login ?? "") + _serverPassword = .init(initialValue: editingBookmark.password ?? "") + } + + var body: some View { + Form { + Section { + TextField(text: $serverName) { + Text("Name") + } + } + + Section { + TextField(text: $serverAddress) { + Text("Address") + } + TextField(text: $serverLogin, prompt: Text("Optional")) { + Text("Login") + } + SecureField(text: $serverPassword, prompt: Text("Optional")) { + Text("Password") + } + } + } + .formStyle(.grouped) + .frame(width: 350) + .fixedSize(horizontal: true, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Server.parseServerAddressAndPort(self.serverAddress) + let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines) + let password = self.serverPassword + + if !displayName.isEmpty && !host.isEmpty { + self.bookmark.name = displayName + self.bookmark.address = host + self.bookmark.port = port + self.bookmark.login = login.isEmpty ? nil : login + self.bookmark.password = password.isEmpty ? nil : password + + self.dismiss() + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift index dc42ea9..93aea15 100644 --- a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift +++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerBookmarkServerView: View { let server: BookmarkServer @@ -35,4 +37,4 @@ struct TrackerBookmarkServerView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } -}
\ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift index 4943016..d66a466 100644 --- a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift +++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerBookmarkSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -116,4 +118,4 @@ struct TrackerBookmarkSheet: View { } } } -}
\ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift index 59caad5..6e62518 100644 --- a/Hotline/macOS/Trackers/TrackerItemView.swift +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerItemView: View { let bookmark: Bookmark let isExpanded: Bool @@ -70,4 +72,4 @@ struct TrackerItemView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } -}
\ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift new file mode 100644 index 0000000..ddc0a67 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -0,0 +1,480 @@ +import SwiftUI +import SwiftData +import Foundation +import UniformTypeIdentifiers + +enum TrackerSelection: Hashable { + case bookmark(Bookmark) + case bookmarkServer(BookmarkServer) + + var server: Server? { + switch self { + case .bookmark(let b): return b.server + case .bookmarkServer(let t): return t.server + } + } +} + +struct TrackerView: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.openWindow) private var openWindow + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.modelContext) private var modelContext + + @State private var refreshing = false + @State private var trackerSheetPresented: Bool = false + @State private var trackerSheetBookmark: Bookmark? = nil + @State private var serverSheetBookmark: Bookmark? = nil + @State private var attemptedPrepopulate: Bool = false + @State private var fileDropActive = false + @State private var bookmarkExportActive = false + @State private var bookmarkExport: BookmarkDocument? = nil + @State private var expandedTrackers: Set<Bookmark> = [] + @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] + @State private var loadingTrackers: Set<Bookmark> = [] + @State private var fetchTasks: [Bookmark: Task<Void, Never>] = [:] + @State private var searchText: String = "" + @State private var isSearching = false + + @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] + @Binding var selection: TrackerSelection? + + private var filteredBookmarks: [Bookmark] { + guard !self.searchText.isEmpty else { + return self.bookmarks + } + + let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) + + return self.bookmarks.filter { bookmark in + // Always show tracker bookmarks (filter only their servers) + if bookmark.type == .tracker { + return true + } + + // Filter server bookmarks by search text + return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords) + } + } + + private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool { + let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased() + + // All search words must match + return searchWords.allSatisfy { word in + searchableText.contains(word) + } + } + + private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] { + let servers = self.trackerServers[bookmark] ?? [] + print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers") + + guard !self.searchText.isEmpty else { + return servers + } + + let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) + + return servers.filter { server in + let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased() + + // All search words must match + return searchWords.allSatisfy { word in + searchableText.contains(word) + } + } + } + + var body: some View { + List(selection: $selection) { + ForEach(filteredBookmarks, id: \.self) { bookmark in + TrackerItemView( + bookmark: bookmark, + isExpanded: self.expandedTrackers.contains(bookmark), + isLoading: self.loadingTrackers.contains(bookmark), + count: self.trackerServers[bookmark]?.count ?? 0 + ) { + self.toggleExpanded(for: bookmark) + } + .tag(TrackerSelection.bookmark(bookmark)) + + if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) { + ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in + TrackerBookmarkServerView(server: trackedServer) + .moveDisabled(true) + .deleteDisabled(true) + .tag(TrackerSelection.bookmarkServer(trackedServer)) + .padding(.leading, 16 + 8 + 10) + } + } + } + .onMove { movedIndexes, destinationIndex in + Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext) + } + .onDelete { deletedIndexes in + Bookmark.delete(at: deletedIndexes, context: modelContext) + } + } + .onDeleteCommand { + switch self.selection { + case .bookmark(let bookmark): + Bookmark.delete(bookmark, context: modelContext) + default: + break + } + +// if let bookmark = selection, +// bookmark.type != .temporary { +// Bookmark.delete(bookmark, context: modelContext) +// } + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .onChange(of: AppState.shared.cloudKitReady) { + if attemptedPrepopulate { + print("Tracker: Already attempted to prepopulate bookmarks") + return + } + + print("Tracker: Prepopulating bookmarks") + + attemptedPrepopulate = true + + // Make sure default bookmarks are there when empty. + Bookmark.populateDefaults(context: modelContext) + } + .onAppear { +// Bookmark.deleteAll(context: modelContext) + } + .contextMenu(forSelectionType: TrackerSelection.self) { items in + if let item = items.first { + switch item { + case .bookmark(let bookmark): + self.bookmarkContextMenu(bookmark) + case .bookmarkServer(let server): + self.bookmarkServerContextMenu(server) + } + } + } primaryAction: { items in + guard let clickedItem = items.first else { + return + } + + switch clickedItem { + case .bookmark(let bookmark): + if bookmark.type == .server { + if let s = bookmark.server { + openWindow(id: "server", value: s) + } + } + else if bookmark.type == .tracker { + if NSEvent.modifierFlags.contains(.option) { + trackerSheetBookmark = bookmark + } + else { + self.toggleExpanded(for: bookmark) + } + } + + case .bookmarkServer(let bookmarkServer): + openWindow(id: "server", value: bookmarkServer.server) + } + } + .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in + switch result { + case .success(let fileURL): + print("Hotline Bookmark: Successfully exported:", fileURL) + case .failure(let err): + print("Hotline Bookmark: Failed to export:", err) + } + + bookmarkExport = nil + bookmarkExportActive = false + }, onCancellation: {}) + .onKeyPress(.rightArrow) { + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.setExpanded(true, for: bookmark) + return .handled + } + default: + break + } + + return .ignored + } + .onKeyPress(.leftArrow) { + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.setExpanded(false, for: bookmark) + return .handled + } + default: + break + } + + return .ignored + } + .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in + for provider in providers { + let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in + // HOTLINE CREATOR CODE: 1213484099 + // HOTLINE BOOKMARK TYPE CODE: 1213489773 + + if let filePathData = dataRepresentation, + let filePath = String(data: filePathData, encoding: .utf8), + let fileURL = URL(string: filePath) { + + print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false)) + + DispatchQueue.main.async { + if let newBookmark = Bookmark(fileURL: fileURL) { + print("Hotline Bookmark: Added bookmark.") + Bookmark.add(newBookmark, context: modelContext) + } + else { + print("Hotline Bookmark: Failed to parse.") + } + } + } + } + } + + return true + } + .sheet(item: $trackerSheetBookmark) { item in + TrackerBookmarkSheet(item) + } + .sheet(isPresented: $trackerSheetPresented) { + TrackerBookmarkSheet() + } + .sheet(item: $serverSheetBookmark) { item in + ServerBookmarkSheet(item) + } + .navigationTitle("Servers") + .toolbar { + if #available(macOS 26.0, *) { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } + .sharedBackgroundVisibility(.hidden) + } + else { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + self.refreshing = true + self.refresh() + self.refreshing = false + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(refreshing) + .help("Refresh Trackers") + } + + ToolbarItem(placement: .primaryAction) { + Button { + trackerSheetPresented = true + } label: { + Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted") + } + .help("Add Tracker") + } + + ToolbarItem(placement: .primaryAction) { + Button { + openWindow(id: "server") + } label: { + Label("Connect to Server", systemImage: "globe.americas.fill") + } + .help("Connect to Server") + } + } + .onOpenURL(perform: { url in + if let s = Server(url: url) { + openWindow(id: "server", value: s) + } + }) + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) + } + + private var hotlineLogoImage: some View { + Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 9) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + @ViewBuilder + func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { + Button { + let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) + Bookmark.add(newBookmark, context: modelContext) + } label: { + Label("Bookmark", systemImage: "bookmark") + } + + Divider() + + Button { + NSPasteboard.general.clearContents() + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString(displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + } + + @ViewBuilder + func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + + Divider() + + if bookmark.type == .tracker { + Button { + trackerSheetBookmark = bookmark + } label: { + Label("Edit Tracker...", systemImage: "pencil") + } + } + + if bookmark.type == .server { + Button { + serverSheetBookmark = bookmark + } label: { + Label("Edit Bookmark...", systemImage: "pencil") + } + + Button { + bookmarkExport = BookmarkDocument(bookmark: bookmark) + bookmarkExportActive = true + } label: { + Label("Export Bookmark...", systemImage: "square.and.arrow.down") + } + } + + Divider() + + Button { + Bookmark.delete(bookmark, context: modelContext) + } label: { + Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") + } + } + + + func refresh() { + // When a tracker is selected, refresh only that tracker. + if let trackerSelection = self.selection { + switch trackerSelection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } + return + } + break + default: + break + } + } + + // Otherwise refresh/expand all trackers. + for bookmark in self.bookmarks { + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } + } + } + } + + func toggleExpanded(for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark) + } + + func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + + if expanded && !self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.insert(bookmark) + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else if !expanded && self.expandedTrackers.contains(bookmark) { + // Cancel ongoing fetch and clear data + self.fetchTasks[bookmark]?.cancel() + self.fetchTasks[bookmark] = nil + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + self.loadingTrackers.remove(bookmark) + } + } + + private func fetchServers(for bookmark: Bookmark) async { + print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)") + self.loadingTrackers.insert(bookmark) + let servers = await bookmark.fetchServers() + print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()") + await MainActor.run { + print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]") + self.trackerServers[bookmark] = servers + self.loadingTrackers.remove(bookmark) + self.fetchTasks[bookmark] = nil // Clean up completed task + print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries") + print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers") + } + } +} + +#if DEBUG +private struct TrackerViewPreview: View { + @State var selection: TrackerSelection? = nil + + var body: some View { + TrackerView(selection: $selection) + } +} + +#Preview { + TrackerViewPreview() +} +#endif |